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

Merge remote-tracking branch 'origin/dev/7.4.x' into feat/page-tree-virtualization

Yuki Takei 4 месяцев назад
Родитель
Сommit
ff0e34e4ad
100 измененных файлов с 7896 добавлено и 4250 удалено
  1. 16 0
      apps/app/.eslintrc.js
  2. 3 1
      apps/app/next.config.js
  3. 5 0
      apps/app/src/features/search/client/components/PrivateLegacyPages.tsx
  4. 14 4
      apps/app/src/pages/[[...path]]/use-shallow-routing.ts
  5. 6 6
      apps/app/src/pages/_private-legacy-pages/index.page.tsx
  6. 1 1
      apps/app/src/pages/general-page/use-initial-skip-ssr-fetch.ts
  7. 101 45
      apps/app/src/server/routes/apiv3/attachment.js
  8. 85 55
      apps/app/src/server/routes/apiv3/bookmarks.js
  9. 424 221
      apps/app/src/server/routes/apiv3/customize-setting.js
  10. 64 36
      apps/app/src/server/routes/apiv3/export.js
  11. 110 62
      apps/app/src/server/routes/apiv3/forgot-password.js
  12. 79 24
      apps/app/src/server/routes/apiv3/index.js
  13. 1 2
      apps/app/src/server/routes/apiv3/logout.js
  14. 110 49
      apps/app/src/server/routes/apiv3/markdown-setting.js
  15. 23 11
      apps/app/src/server/routes/apiv3/mongo.js
  16. 243 176
      apps/app/src/server/routes/apiv3/notification-setting.js
  17. 4 3
      apps/app/src/server/routes/apiv3/response.js
  18. 74 35
      apps/app/src/server/routes/apiv3/revisions.js
  19. 80 27
      apps/app/src/server/routes/apiv3/search.js
  20. 167 119
      apps/app/src/server/routes/apiv3/share-links.js
  21. 62 28
      apps/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  22. 490 271
      apps/app/src/server/routes/apiv3/slack-integration-settings.js
  23. 315 153
      apps/app/src/server/routes/apiv3/slack-integration.js
  24. 10 12
      apps/app/src/server/routes/apiv3/staffs.js
  25. 45 39
      apps/app/src/server/routes/apiv3/statistics.js
  26. 44 21
      apps/app/src/server/routes/apiv3/user-group-relation.js
  27. 349 192
      apps/app/src/server/routes/apiv3/user-group.js
  28. 402 224
      apps/app/src/server/routes/apiv3/users.js
  29. 17 13
      apps/app/src/server/service/access-token/access-token-deletion-cron.ts
  30. 32 36
      apps/app/src/server/service/acl.integ.ts
  31. 5 7
      apps/app/src/server/service/acl.ts
  32. 99 57
      apps/app/src/server/service/activity.ts
  33. 5 7
      apps/app/src/server/service/app.ts
  34. 51 25
      apps/app/src/server/service/attachment.ts
  35. 22 19
      apps/app/src/server/service/comment.ts
  36. 154 92
      apps/app/src/server/service/config-manager/config-definition.ts
  37. 17 10
      apps/app/src/server/service/config-manager/config-loader.spec.ts
  38. 19 14
      apps/app/src/server/service/config-manager/config-loader.ts
  39. 100 53
      apps/app/src/server/service/config-manager/config-manager.integ.ts
  40. 77 44
      apps/app/src/server/service/config-manager/config-manager.spec.ts
  41. 56 36
      apps/app/src/server/service/config-manager/config-manager.ts
  42. 2 5
      apps/app/src/server/service/cron.ts
  43. 36 22
      apps/app/src/server/service/customize.ts
  44. 55 30
      apps/app/src/server/service/export.ts
  45. 24 19
      apps/app/src/server/service/external-account.ts
  46. 12 8
      apps/app/src/server/service/file-uploader-switch.ts
  47. 217 112
      apps/app/src/server/service/g2g-transfer.ts
  48. 18 17
      apps/app/src/server/service/i18next.ts
  49. 92 58
      apps/app/src/server/service/in-app-notification.ts
  50. 74 47
      apps/app/src/server/service/installer.ts
  51. 81 51
      apps/app/src/server/service/ldap.ts
  52. 37 31
      apps/app/src/server/service/mail.ts
  53. 513 206
      apps/app/src/server/service/page-grant.ts
  54. 80 42
      apps/app/src/server/service/page-listing/page-listing.integ.ts
  55. 71 38
      apps/app/src/server/service/page-listing/page-listing.ts
  56. 97 40
      apps/app/src/server/service/page-operation.ts
  57. 60 56
      apps/app/src/server/service/passport.spec.ts
  58. 344 174
      apps/app/src/server/service/passport.ts
  59. 27 22
      apps/app/src/server/service/pre-notify.ts
  60. 9 10
      apps/app/src/server/service/rest-qiita-API.js
  61. 26 17
      apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts
  62. 26 10
      apps/app/src/server/service/revision/normalize-latest-revision-if-broken.ts
  63. 4 6
      apps/app/src/server/service/s2s-messaging/base.ts
  64. 0 2
      apps/app/src/server/service/s2s-messaging/handlable.ts
  65. 3 3
      apps/app/src/server/service/s2s-messaging/index.ts
  66. 38 22
      apps/app/src/server/service/s2s-messaging/nchan.ts
  67. 1 1
      apps/app/src/server/service/s2s-messaging/redis.ts
  68. 7 5
      apps/app/src/server/service/search-delegator/aggregate-to-index.ts
  69. 23 22
      apps/app/src/server/service/search-delegator/bulk-write.d.ts
  70. 67 23
      apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es7-client-delegator.ts
  71. 58 17
      apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es8-client-delegator.ts
  72. 58 17
      apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es9-client-delegator.ts
  73. 44 31
      apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/get-client.ts
  74. 27 17
      apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/interfaces.ts
  75. 248 127
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  76. 48 21
      apps/app/src/server/service/search-delegator/private-legacy-pages.ts
  77. 6 5
      apps/app/src/server/service/search-reconnect-context/reconnect-context.js
  78. 266 119
      apps/app/src/server/service/search.ts
  79. 18 6
      apps/app/src/server/service/slack-command-handler/create-page-service.js
  80. 35 15
      apps/app/src/server/service/slack-command-handler/error-handler.ts
  81. 6 6
      apps/app/src/server/service/slack-command-handler/help.js
  82. 156 62
      apps/app/src/server/service/slack-command-handler/keep.js
  83. 60 13
      apps/app/src/server/service/slack-command-handler/note.js
  84. 143 89
      apps/app/src/server/service/slack-command-handler/search.js
  85. 11 4
      apps/app/src/server/service/slack-command-handler/slack-command-handler.js
  86. 135 54
      apps/app/src/server/service/slack-command-handler/togetter.js
  87. 10 4
      apps/app/src/server/service/slack-event-handler/base-event-handler.ts
  88. 105 63
      apps/app/src/server/service/slack-event-handler/link-shared.ts
  89. 115 53
      apps/app/src/server/service/slack-integration.ts
  90. 4 1
      apps/app/src/server/service/socket-io/helper.ts
  91. 27 22
      apps/app/src/server/service/socket-io/socket-io.ts
  92. 25 15
      apps/app/src/server/service/system-events/sync-page-status.ts
  93. 101 35
      apps/app/src/server/service/user-group.ts
  94. 31 14
      apps/app/src/server/service/user-notification/index.ts
  95. 2 4
      apps/app/src/server/service/yjs/create-indexes.ts
  96. 12 5
      apps/app/src/server/service/yjs/create-mongodb-persistence.ts
  97. 14 8
      apps/app/src/server/service/yjs/extended/mongodb-persistence.ts
  98. 34 24
      apps/app/src/server/service/yjs/sync-ydoc.ts
  99. 26 26
      apps/app/src/server/service/yjs/yjs.integ.ts
  100. 46 44
      apps/app/src/server/service/yjs/yjs.ts

+ 16 - 0
apps/app/.eslintrc.js

@@ -60,6 +60,22 @@ module.exports = {
     'src/server/routes/apiv3/app-settings/**',
     'src/server/routes/apiv3/app-settings/**',
     'src/server/routes/apiv3/page/**',
     'src/server/routes/apiv3/page/**',
     'src/server/routes/apiv3/*.ts',
     'src/server/routes/apiv3/*.ts',
+    'src/server/service/*.ts',
+    'src/server/service/*.js',
+    'src/server/service/access-token/**',
+    'src/server/service/config-manager/**',
+    'src/server/service/page/**',
+    'src/server/service/page-listing/**',
+    'src/server/service/revision/**',
+    'src/server/service/s2s-messaging/**',
+    'src/server/service/search-delegator/**',
+    'src/server/service/search-reconnect-context/**',
+    'src/server/service/slack-command-handler/**',
+    'src/server/service/slack-event-handler/**',
+    'src/server/service/socket-io/**',
+    'src/server/service/system-events/**',
+    'src/server/service/user-notification/**',
+    'src/server/service/yjs/**',
   ],
   ],
   settings: {
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     // resolve path aliases by eslint-import-resolver-typescript

+ 3 - 1
apps/app/next.config.js

@@ -160,8 +160,10 @@ module.exports = async (phase) => {
   };
   };
 
 
   // production server
   // production server
+  // Skip withSuperjson() in production server phase because the pages directory
+  // doesn't exist in the production build and withSuperjson() tries to find it
   if (phase === PHASE_PRODUCTION_SERVER) {
   if (phase === PHASE_PRODUCTION_SERVER) {
-    return withSuperjson()(nextConfig);
+    return nextConfig;
   }
   }
 
 
   const withBundleAnalyzer = require('@next/bundle-analyzer')({
   const withBundleAnalyzer = require('@next/bundle-analyzer')({

+ 5 - 0
apps/app/src/features/search/client/components/PrivateLegacyPages.tsx

@@ -281,6 +281,11 @@ const PrivateLegacyPages = (): JSX.Element => {
     (ISelectableAll & IReturnSelectedPageIds) | null
     (ISelectableAll & IReturnSelectedPageIds) | null
   >(null);
   >(null);
 
 
+  // biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount
+  useEffect(() => {
+    setSearchKeyword(initQ);
+  }, []);
+
   const { data, conditions, mutate } = useSWRxSearch(
   const { data, conditions, mutate } = useSWRxSearch(
     keyword,
     keyword,
     'PrivateLegacyPages',
     'PrivateLegacyPages',

+ 14 - 4
apps/app/src/pages/[[...path]]/use-shallow-routing.ts

@@ -11,16 +11,25 @@ import type { CommonEachProps } from '../common-props';
 export const useShallowRouting = (props: CommonEachProps): void => {
 export const useShallowRouting = (props: CommonEachProps): void => {
   const router = useRouter();
   const router = useRouter();
   const lastPathnameRef = useRef<string>();
   const lastPathnameRef = useRef<string>();
+  const lastBrowserUrlRef = useRef<string>();
 
 
   // Sync pathname by Shallow Routing with performance optimization
   // Sync pathname by Shallow Routing with performance optimization
   useEffect(() => {
   useEffect(() => {
     if (!isClient() || !props.currentPathname) return;
     if (!isClient() || !props.currentPathname) return;
 
 
-    // Skip if pathname hasn't changed (prevents unnecessary operations)
-    if (lastPathnameRef.current === props.currentPathname) return;
-
     const currentURL = decodeURI(window.location.pathname);
     const currentURL = decodeURI(window.location.pathname);
 
 
+    // Skip if both props.currentPathname and browser URL haven't changed
+    // This handles the case where:
+    // 1. props.currentPathname is the same (e.g., /${pageId})
+    // 2. But browser URL changed via navigation (e.g., /path/to/page)
+    if (
+      lastPathnameRef.current === props.currentPathname &&
+      lastBrowserUrlRef.current === currentURL
+    ) {
+      return;
+    }
+
     // Only update if URLs actually differ
     // Only update if URLs actually differ
     if (currentURL !== props.currentPathname) {
     if (currentURL !== props.currentPathname) {
       const { search, hash } = window.location;
       const { search, hash } = window.location;
@@ -29,7 +38,8 @@ export const useShallowRouting = (props: CommonEachProps): void => {
       });
       });
     }
     }
 
 
-    // Update reference for next comparison
+    // Update references for next comparison
     lastPathnameRef.current = props.currentPathname;
     lastPathnameRef.current = props.currentPathname;
+    lastBrowserUrlRef.current = currentURL;
   }, [props.currentPathname, router]);
   }, [props.currentPathname, router]);
 };
 };

+ 6 - 6
apps/app/src/pages/_private-legacy-pages/index.page.tsx

@@ -21,6 +21,12 @@ const SearchResultLayout = dynamic(
   { ssr: false },
   { ssr: false },
 );
 );
 
 
+const PrivateLegacyPages = dynamic(
+  // biome-ignore lint/style/noRestrictedImports: no-problem dynamic import
+  () => import('~/features/search/client/components/PrivateLegacyPages'),
+  { ssr: false },
+);
+
 type Props = CommonInitialProps &
 type Props = CommonInitialProps &
   CommonEachProps &
   CommonEachProps &
   BasicLayoutConfigurationProps &
   BasicLayoutConfigurationProps &
@@ -30,12 +36,6 @@ type Props = CommonInitialProps &
 const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
 const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const PrivateLegacyPages = dynamic(
-    // biome-ignore lint/style/noRestrictedImports: no-problem dynamic import
-    () => import('~/features/search/client/components/PrivateLegacyPages'),
-    { ssr: false },
-  );
-
   // clear the cache for the current page
   // clear the cache for the current page
   //  in order to fix https://redmine.weseek.co.jp/issues/135811
   //  in order to fix https://redmine.weseek.co.jp/issues/135811
   // useHydratePageAtoms(undefined);
   // useHydratePageAtoms(undefined);

+ 1 - 1
apps/app/src/pages/general-page/use-initial-skip-ssr-fetch.ts

@@ -14,7 +14,7 @@ export const useInitialCSRFetch = (shouldFetch?: boolean): void => {
 
 
   useEffect(() => {
   useEffect(() => {
     if (shouldFetch) {
     if (shouldFetch) {
-      fetchCurrentPage();
+      fetchCurrentPage({ force: true });
     }
     }
   }, [fetchCurrentPage, shouldFetch]);
   }, [fetchCurrentPage, shouldFetch]);
 };
 };

+ 101 - 45
apps/app/src/server/routes/apiv3/attachment.js

@@ -9,7 +9,10 @@ import { SupportedAction } from '~/interfaces/activity';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { Attachment } from '~/server/models/attachment';
 import { Attachment } from '~/server/models/attachment';
-import { serializePageSecurely, serializeRevisionSecurely } from '~/server/models/serializers';
+import {
+  serializePageSecurely,
+  serializeRevisionSecurely,
+} from '~/server/models/serializers';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -17,14 +20,10 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify-shared-page-attachment';
 import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify-shared-page-attachment';
 import { excludeReadOnlyUser } from '../../middlewares/exclude-read-only-user';
 import { excludeReadOnlyUser } from '../../middlewares/exclude-read-only-user';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
 
 
 const router = express.Router();
 const router = express.Router();
-const {
-  query, param, body,
-} = require('express-validator');
-
+const { query, param, body } = require('express-validator');
 
 
 /**
 /**
  * @swagger
  * @swagger
@@ -135,8 +134,13 @@ const {
  */
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequired = require('../../middlewares/login-required')(
+    crowi,
+    true,
+  );
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
   const Page = crowi.model('Page');
   const Page = crowi.model('Page');
   const User = crowi.model('User');
   const User = crowi.model('User');
   const { attachmentService } = crowi;
   const { attachmentService } = crowi;
@@ -151,14 +155,26 @@ module.exports = (crowi) => {
     ],
     ],
     retrieveAttachments: [
     retrieveAttachments: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageId').isMongoId().withMessage('pageId is required'),
-      query('pageNumber').optional().isInt().withMessage('pageNumber must be a number'),
-      query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
+      query('pageNumber')
+        .optional()
+        .isInt()
+        .withMessage('pageNumber must be a number'),
+      query('limit')
+        .optional()
+        .isInt({ max: 100 })
+        .withMessage('You should set less than 100 or not to set limit.'),
     ],
     ],
     retrieveFileLimit: [
     retrieveFileLimit: [
-      query('fileSize').isNumeric().exists({ checkNull: true }).withMessage('fileSize is required'),
+      query('fileSize')
+        .isNumeric()
+        .exists({ checkNull: true })
+        .withMessage('fileSize is required'),
     ],
     ],
     retrieveAddAttachment: [
     retrieveAddAttachment: [
-      body('page_id').isMongoId().exists({ checkNull: true }).withMessage('page_id is required'),
+      body('page_id')
+        .isMongoId()
+        .exists({ checkNull: true })
+        .withMessage('page_id is required'),
     ],
     ],
   };
   };
 
 
@@ -199,18 +215,29 @@ module.exports = (crowi) => {
    *                  type: object
    *                  type: object
    *                  $ref: '#/components/schemas/AttachmentPaginateResult'
    *                  $ref: '#/components/schemas/AttachmentPaginateResult'
    */
    */
-  router.get('/list',
-    accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT], { acceptLegacy: true }), loginRequired, validator.retrieveAttachments, apiV3FormValidator,
-    async(req, res) => {
-
-      const limit = req.query.limit || await crowi.configManager.getConfig('customize:showPageLimitationS') || 10;
+  router.get(
+    '/list',
+    accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT], { acceptLegacy: true }),
+    loginRequired,
+    validator.retrieveAttachments,
+    apiV3FormValidator,
+    async (req, res) => {
+      const limit =
+        req.query.limit ||
+        (await crowi.configManager.getConfig(
+          'customize:showPageLimitationS',
+        )) ||
+        10;
       const pageNumber = req.query.pageNumber || 1;
       const pageNumber = req.query.pageNumber || 1;
       const offset = (pageNumber - 1) * limit;
       const offset = (pageNumber - 1) * limit;
 
 
       try {
       try {
         const pageId = req.query.pageId;
         const pageId = req.query.pageId;
         // check whether accessible
         // check whether accessible
-        const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
+        const isAccessible = await Page.isAccessiblePageByViewer(
+          pageId,
+          req.user,
+        );
         if (!isAccessible) {
         if (!isAccessible) {
           const msg = 'Current user is not accessible to this page.';
           const msg = 'Current user is not accessible to this page.';
           return res.apiv3Err(new ErrorV3(msg, 'attachment-list-failed'), 403);
           return res.apiv3Err(new ErrorV3(msg, 'attachment-list-failed'), 403);
@@ -234,13 +261,12 @@ module.exports = (crowi) => {
         });
         });
 
 
         return res.apiv3({ paginateResult });
         return res.apiv3({ paginateResult });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Attachment not found', err);
         logger.error('Attachment not found', err);
         return res.apiv3Err(err, 500);
         return res.apiv3Err(err, 500);
       }
       }
-    });
-
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -274,19 +300,23 @@ module.exports = (crowi) => {
    *          500:
    *          500:
    *            $ref: '#/components/responses/InternalServerError'
    *            $ref: '#/components/responses/InternalServerError'
    */
    */
-  router.get('/limit',
-    accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT], { acceptLegacy: true }), loginRequiredStrictly, validator.retrieveFileLimit, apiV3FormValidator,
-    async(req, res) => {
+  router.get(
+    '/limit',
+    accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    validator.retrieveFileLimit,
+    apiV3FormValidator,
+    async (req, res) => {
       const { fileUploadService } = crowi;
       const { fileUploadService } = crowi;
       const fileSize = Number(req.query.fileSize);
       const fileSize = Number(req.query.fileSize);
       try {
       try {
         return res.apiv3(await fileUploadService.checkLimit(fileSize));
         return res.apiv3(await fileUploadService.checkLimit(fileSize));
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('File limit retrieval failed', err);
         logger.error('File limit retrieval failed', err);
         return res.apiv3Err(err, 500);
         return res.apiv3Err(err, 500);
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -342,11 +372,19 @@ module.exports = (crowi) => {
    *          500:
    *          500:
    *            $ref: '#/components/responses/InternalServerError'
    *            $ref: '#/components/responses/InternalServerError'
    */
    */
-  router.post('/', uploads.single('file'), accessTokenParser([SCOPE.WRITE.FEATURES.ATTACHMENT], { acceptLegacy: true }),
-    loginRequiredStrictly, excludeReadOnlyUser, validator.retrieveAddAttachment, apiV3FormValidator, addActivity,
+  router.post(
+    '/',
+    uploads.single('file'),
+    accessTokenParser([SCOPE.WRITE.FEATURES.ATTACHMENT], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    validator.retrieveAddAttachment,
+    apiV3FormValidator,
+    addActivity,
     // Removed autoReap middleware to use file data in asynchronous processes. Instead, implemented file deletion after asynchronous processes complete
     // Removed autoReap middleware to use file data in asynchronous processes. Instead, implemented file deletion after asynchronous processes complete
-    async(req, res) => {
-
+    async (req, res) => {
       const pageId = req.body.page_id;
       const pageId = req.body.page_id;
 
 
       // check params
       // check params
@@ -359,12 +397,21 @@ module.exports = (crowi) => {
         const page = await Page.findOne({ _id: { $eq: pageId } });
         const page = await Page.findOne({ _id: { $eq: pageId } });
 
 
         // check the user is accessible
         // check the user is accessible
-        const isAccessible = await Page.isAccessiblePageByViewer(page.id, req.user);
+        const isAccessible = await Page.isAccessiblePageByViewer(
+          page.id,
+          req.user,
+        );
         if (!isAccessible) {
         if (!isAccessible) {
           return res.apiv3Err(`Forbidden to access to the page '${page.id}'`);
           return res.apiv3Err(`Forbidden to access to the page '${page.id}'`);
         }
         }
 
 
-        const attachment = await attachmentService.createAttachment(file, req.user, pageId, AttachmentType.WIKI_PAGE, () => autoReap(req, res, () => {}));
+        const attachment = await attachmentService.createAttachment(
+          file,
+          req.user,
+          pageId,
+          AttachmentType.WIKI_PAGE,
+          () => autoReap(req, res, () => {}),
+        );
 
 
         const result = {
         const result = {
           page: serializePageSecurely(page),
           page: serializePageSecurely(page),
@@ -372,15 +419,17 @@ module.exports = (crowi) => {
           attachment: attachment.toObject({ virtuals: true }),
           attachment: attachment.toObject({ virtuals: true }),
         };
         };
 
 
-        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ATTACHMENT_ADD });
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_ATTACHMENT_ADD,
+        });
 
 
         res.apiv3(result);
         res.apiv3(result);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err(err.message);
         return res.apiv3Err(err.message);
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -407,13 +456,20 @@ module.exports = (crowi) => {
    *            schema:
    *            schema:
    *              type: string
    *              type: string
    */
    */
-  router.get('/:id', accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT], { acceptLegacy: true }), certifySharedPageAttachmentMiddleware, loginRequired,
-    validator.retrieveAttachment, apiV3FormValidator,
-    async(req, res) => {
+  router.get(
+    '/:id',
+    accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT], { acceptLegacy: true }),
+    certifySharedPageAttachmentMiddleware,
+    loginRequired,
+    validator.retrieveAttachment,
+    apiV3FormValidator,
+    async (req, res) => {
       try {
       try {
         const attachmentId = req.params.id;
         const attachmentId = req.params.id;
 
 
-        const attachment = await Attachment.findById(attachmentId).populate('creator').exec();
+        const attachment = await Attachment.findById(attachmentId)
+          .populate('creator')
+          .exec();
 
 
         if (attachment == null) {
         if (attachment == null) {
           const message = 'Attachment not found';
           const message = 'Attachment not found';
@@ -425,12 +481,12 @@ module.exports = (crowi) => {
         }
         }
 
 
         return res.apiv3({ attachment });
         return res.apiv3({ attachment });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Attachment retrieval failed', err);
         logger.error('Attachment retrieval failed', err);
         return res.apiv3Err(err, 500);
         return res.apiv3Err(err, 500);
       }
       }
-    });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 85 - 55
apps/app/src/server/routes/apiv3/bookmarks.js

@@ -1,7 +1,7 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
@@ -16,7 +16,6 @@ const logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-
 const express = require('express');
 const express = require('express');
 const { body, query, param } = require('express-validator');
 const { body, query, param } = require('express-validator');
 
 
-
 const router = express.Router();
 const router = express.Router();
 
 
 /**
 /**
@@ -85,8 +84,13 @@ const router = express.Router();
  */
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
+  const loginRequired = require('../../middlewares/login-required')(
+    crowi,
+    true,
+  );
   const addActivity = generateAddActivityMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
@@ -94,13 +98,8 @@ module.exports = (crowi) => {
   const { Page, Bookmark } = crowi.models;
   const { Page, Bookmark } = crowi.models;
 
 
   const validator = {
   const validator = {
-    bookmarks: [
-      body('pageId').isString(),
-      body('bool').isBoolean(),
-    ],
-    bookmarkInfo: [
-      query('pageId').isMongoId(),
-    ],
+    bookmarks: [body('pageId').isString(), body('bool').isBoolean()],
+    bookmarkInfo: [query('pageId').isMongoId()],
   };
   };
 
 
   /**
   /**
@@ -125,24 +124,32 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/BookmarkInfo'
    *                  $ref: '#/components/schemas/BookmarkInfo'
    */
    */
-  router.get('/info',
-    accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequired, validator.bookmarkInfo, apiV3FormValidator, async(req, res) => {
+  router.get(
+    '/info',
+    accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }),
+    loginRequired,
+    validator.bookmarkInfo,
+    apiV3FormValidator,
+    async (req, res) => {
       const { user } = req;
       const { user } = req;
       const { pageId } = req.query;
       const { pageId } = req.query;
 
 
       const responsesParams = {};
       const responsesParams = {};
 
 
       try {
       try {
-        const bookmarks = await Bookmark.find({ page: pageId }).populate('user');
+        const bookmarks = await Bookmark.find({ page: pageId }).populate(
+          'user',
+        );
         let users = [];
         let users = [];
         if (bookmarks.length > 0) {
         if (bookmarks.length > 0) {
-          users = bookmarks.map(bookmark => serializeUserSecurely(bookmark.user));
+          users = bookmarks.map((bookmark) =>
+            serializeUserSecurely(bookmark.user),
+          );
         }
         }
         responsesParams.sumOfBookmarks = bookmarks.length;
         responsesParams.sumOfBookmarks = bookmarks.length;
         responsesParams.bookmarkedUsers = users;
         responsesParams.bookmarkedUsers = users;
         responsesParams.pageId = pageId;
         responsesParams.pageId = pageId;
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('get-bookmark-document-failed', err);
         logger.error('get-bookmark-document-failed', err);
         return res.apiv3Err(err, 500);
         return res.apiv3Err(err, 500);
       }
       }
@@ -154,15 +161,14 @@ module.exports = (crowi) => {
 
 
       try {
       try {
         const bookmark = await Bookmark.findByPageIdAndUserId(pageId, user._id);
         const bookmark = await Bookmark.findByPageIdAndUserId(pageId, user._id);
-        responsesParams.isBookmarked = (bookmark != null);
+        responsesParams.isBookmarked = bookmark != null;
         return res.apiv3(responsesParams);
         return res.apiv3(responsesParams);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('get-bookmark-state-failed', err);
         logger.error('get-bookmark-state-failed', err);
         return res.apiv3Err(err, 500);
         return res.apiv3Err(err, 500);
       }
       }
-
-    });
+    },
+  );
 
 
   // select page from bookmark where userid = userid
   // select page from bookmark where userid = userid
   /**
   /**
@@ -192,39 +198,49 @@ module.exports = (crowi) => {
     param('userId').isMongoId().withMessage('userId is required'),
     param('userId').isMongoId().withMessage('userId is required'),
   ];
   ];
 
 
-  router.get('/:userId',
+  router.get(
+    '/:userId',
     accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }),
     accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }),
-    loginRequired, validator.userBookmarkList, apiV3FormValidator, async(req, res) => {
+    loginRequired,
+    validator.userBookmarkList,
+    apiV3FormValidator,
+    async (req, res) => {
       const { userId } = req.params;
       const { userId } = req.params;
 
 
       if (userId == null) {
       if (userId == null) {
         return res.apiv3Err('User id is not found or forbidden', 400);
         return res.apiv3Err('User id is not found or forbidden', 400);
       }
       }
       try {
       try {
-        const bookmarkIdsInFolders = await BookmarkFolder.distinct('bookmarks', { owner: userId });
+        const bookmarkIdsInFolders = await BookmarkFolder.distinct(
+          'bookmarks',
+          { owner: userId },
+        );
         const userRootBookmarks = await Bookmark.find({
         const userRootBookmarks = await Bookmark.find({
           _id: { $nin: bookmarkIdsInFolders },
           _id: { $nin: bookmarkIdsInFolders },
           user: userId,
           user: userId,
-        }).populate({
-          path: 'page',
-          model: 'Page',
-          populate: {
-            path: 'lastUpdateUser',
-            model: 'User',
-          },
-        }).exec();
+        })
+          .populate({
+            path: 'page',
+            model: 'Page',
+            populate: {
+              path: 'lastUpdateUser',
+              model: 'User',
+            },
+          })
+          .exec();
 
 
         // serialize Bookmark
         // serialize Bookmark
-        const serializedUserRootBookmarks = userRootBookmarks.map(bookmark => serializeBookmarkSecurely(bookmark));
+        const serializedUserRootBookmarks = userRootBookmarks.map((bookmark) =>
+          serializeBookmarkSecurely(bookmark),
+        );
 
 
         return res.apiv3({ userRootBookmarks: serializedUserRootBookmarks });
         return res.apiv3({ userRootBookmarks: serializedUserRootBookmarks });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('get-bookmark-failed', err);
         logger.error('get-bookmark-failed', err);
         return res.apiv3Err(err, 500);
         return res.apiv3Err(err, 500);
       }
       }
-    });
-
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -250,9 +266,14 @@ module.exports = (crowi) => {
    *                    bookmark:
    *                    bookmark:
    *                      $ref: '#/components/schemas/Bookmark'
    *                      $ref: '#/components/schemas/Bookmark'
    */
    */
-  router.put('/',
-    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, addActivity, validator.bookmarks, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/',
+    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    addActivity,
+    validator.bookmarks,
+    apiV3FormValidator,
+    async (req, res) => {
       const { pageId, bool } = req.body;
       const { pageId, bool } = req.body;
       const userId = req.user?._id;
       const userId = req.user?._id;
 
 
@@ -273,22 +294,22 @@ module.exports = (crowi) => {
         if (bookmark == null) {
         if (bookmark == null) {
           if (bool) {
           if (bool) {
             bookmark = await Bookmark.add(page, req.user);
             bookmark = await Bookmark.add(page, req.user);
+          } else {
+            logger.warn(
+              `Removing the bookmark for ${page._id} by ${req.user._id} failed because the bookmark does not exist.`,
+            );
           }
           }
-          else {
-            logger.warn(`Removing the bookmark for ${page._id} by ${req.user._id} failed because the bookmark does not exist.`);
-          }
-        }
-        else {
-        // eslint-disable-next-line no-lonely-if
+        } else {
+          // eslint-disable-next-line no-lonely-if
           if (bool) {
           if (bool) {
-            logger.warn(`Adding the bookmark for ${page._id} by ${req.user._id} failed because the bookmark has already exist.`);
-          }
-          else {
+            logger.warn(
+              `Adding the bookmark for ${page._id} by ${req.user._id} failed because the bookmark has already exist.`,
+            );
+          } else {
             bookmark = await Bookmark.removeBookmark(page, req.user);
             bookmark = await Bookmark.removeBookmark(page, req.user);
           }
           }
         }
         }
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('update-bookmark-failed', err);
         logger.error('update-bookmark-failed', err);
         return res.apiv3Err(err, 500);
         return res.apiv3Err(err, 500);
       }
       }
@@ -301,13 +322,22 @@ module.exports = (crowi) => {
       const parameters = {
       const parameters = {
         targetModel: SupportedTargetModel.MODEL_PAGE,
         targetModel: SupportedTargetModel.MODEL_PAGE,
         target: page,
         target: page,
-        action: bool ? SupportedAction.ACTION_PAGE_BOOKMARK : SupportedAction.ACTION_PAGE_UNBOOKMARK,
+        action: bool
+          ? SupportedAction.ACTION_PAGE_BOOKMARK
+          : SupportedAction.ACTION_PAGE_UNBOOKMARK,
       };
       };
 
 
-      activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
+      activityEvent.emit(
+        'update',
+        res.locals.activity._id,
+        parameters,
+        page,
+        preNotifyService.generatePreNotify,
+      );
 
 
       return res.apiv3({ bookmark });
       return res.apiv3({ bookmark });
-    });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 424 - 221
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -1,6 +1,7 @@
 /* eslint-disable no-unused-vars */
 /* eslint-disable no-unused-vars */
 
 
 import { GrowiPluginType } from '@growi/core';
 import { GrowiPluginType } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import express from 'express';
 import express from 'express';
 import { body } from 'express-validator';
 import { body } from 'express-validator';
@@ -8,7 +9,6 @@ import multer from 'multer';
 
 
 import { GrowiPlugin } from '~/features/growi-plugin/server/models';
 import { GrowiPlugin } from '~/features/growi-plugin/server/models';
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { Attachment } from '~/server/models/attachment';
 import { Attachment } from '~/server/models/attachment';
@@ -18,12 +18,10 @@ import loggerFactory from '~/utils/logger';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:customize-setting');
 const logger = loggerFactory('growi:routes:apiv3:customize-setting');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
-
 /**
 /**
  * @swagger
  * @swagger
  *
  *
@@ -195,7 +193,9 @@ const router = express.Router();
  */
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
 
@@ -204,12 +204,8 @@ module.exports = (crowi) => {
   const { customizeService, attachmentService } = crowi;
   const { customizeService, attachmentService } = crowi;
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const validator = {
   const validator = {
-    layout: [
-      body('isContainerFluid').isBoolean(),
-    ],
-    theme: [
-      body('theme').isString(),
-    ],
+    layout: [body('isContainerFluid').isBoolean()],
+    theme: [body('theme').isString()],
     sidebar: [
     sidebar: [
       body('isSidebarCollapsedMode').isBoolean(),
       body('isSidebarCollapsedMode').isBoolean(),
       body('isSidebarClosedAtDockMode').optional().isBoolean(),
       body('isSidebarClosedAtDockMode').optional().isBoolean(),
@@ -226,27 +222,28 @@ module.exports = (crowi) => {
       body('isSearchScopeChildrenAsDefault').isBoolean(),
       body('isSearchScopeChildrenAsDefault').isBoolean(),
       body('showPageSideAuthors').isBoolean(),
       body('showPageSideAuthors').isBoolean(),
     ],
     ],
-    CustomizePresentation: [
-      body('isEnabledMarp').isBoolean(),
-    ],
-    customizeTitle: [
-      body('customizeTitle').isString(),
-    ],
+    CustomizePresentation: [body('isEnabledMarp').isBoolean()],
+    customizeTitle: [body('customizeTitle').isString()],
     highlight: [
     highlight: [
-      body('highlightJsStyle').isString().isIn([
-        'github', 'github-gist', 'atom-one-light', 'xcode', 'vs', 'atom-one-dark', 'hybrid', 'monokai', 'tomorrow-night', 'vs2015',
-      ]),
+      body('highlightJsStyle')
+        .isString()
+        .isIn([
+          'github',
+          'github-gist',
+          'atom-one-light',
+          'xcode',
+          'vs',
+          'atom-one-dark',
+          'hybrid',
+          'monokai',
+          'tomorrow-night',
+          'vs2015',
+        ]),
       body('highlightJsStyleBorder').isBoolean(),
       body('highlightJsStyleBorder').isBoolean(),
     ],
     ],
-    customizeScript: [
-      body('customizeScript').isString(),
-    ],
-    customizeCss: [
-      body('customizeCss').isString(),
-    ],
-    customizeNoscript: [
-      body('customizeNoscript').isString(),
-    ],
+    customizeScript: [body('customizeScript').isString()],
+    customizeCss: [body('customizeCss').isString()],
+    customizeNoscript: [body('customizeNoscript').isString()],
     logo: [
     logo: [
       body('isDefaultLogo').isBoolean().optional({ nullable: true }),
       body('isDefaultLogo').isBoolean().optional({ nullable: true }),
       body('customizedLogoSrc').isString().optional({ nullable: true }),
       body('customizedLogoSrc').isString().optional({ nullable: true }),
@@ -275,29 +272,57 @@ module.exports = (crowi) => {
    *                      description: customize params
    *                      description: customize params
    *                      $ref: '#/components/schemas/CustomizeSetting'
    *                      $ref: '#/components/schemas/CustomizeSetting'
    */
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, async(req, res) => {
-    const customizeParams = {
-      isEnabledTimeline: await configManager.getConfig('customize:isEnabledTimeline'),
-      isEnabledAttachTitleHeader: await configManager.getConfig('customize:isEnabledAttachTitleHeader'),
-      pageLimitationS: await configManager.getConfig('customize:showPageLimitationS'),
-      pageLimitationM: await configManager.getConfig('customize:showPageLimitationM'),
-      pageLimitationL: await configManager.getConfig('customize:showPageLimitationL'),
-      pageLimitationXL: await configManager.getConfig('customize:showPageLimitationXL'),
-      isEnabledStaleNotification: await configManager.getConfig('customize:isEnabledStaleNotification'),
-      isAllReplyShown: await configManager.getConfig('customize:isAllReplyShown'),
-      showPageSideAuthors: await configManager.getConfig('customize:showPageSideAuthors'),
-      isSearchScopeChildrenAsDefault: await configManager.getConfig('customize:isSearchScopeChildrenAsDefault'),
-      isEnabledMarp: await configManager.getConfig('customize:isEnabledMarp'),
-      styleName: await configManager.getConfig('customize:highlightJsStyle'),
-      styleBorder: await configManager.getConfig('customize:highlightJsStyleBorder'),
-      customizeTitle: await configManager.getConfig('customize:title'),
-      customizeScript: await configManager.getConfig('customize:script'),
-      customizeCss: await configManager.getConfig('customize:css'),
-      customizeNoscript: await configManager.getConfig('customize:noscript'),
-    };
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      const customizeParams = {
+        isEnabledTimeline: await configManager.getConfig(
+          'customize:isEnabledTimeline',
+        ),
+        isEnabledAttachTitleHeader: await configManager.getConfig(
+          'customize:isEnabledAttachTitleHeader',
+        ),
+        pageLimitationS: await configManager.getConfig(
+          'customize:showPageLimitationS',
+        ),
+        pageLimitationM: await configManager.getConfig(
+          'customize:showPageLimitationM',
+        ),
+        pageLimitationL: await configManager.getConfig(
+          'customize:showPageLimitationL',
+        ),
+        pageLimitationXL: await configManager.getConfig(
+          'customize:showPageLimitationXL',
+        ),
+        isEnabledStaleNotification: await configManager.getConfig(
+          'customize:isEnabledStaleNotification',
+        ),
+        isAllReplyShown: await configManager.getConfig(
+          'customize:isAllReplyShown',
+        ),
+        showPageSideAuthors: await configManager.getConfig(
+          'customize:showPageSideAuthors',
+        ),
+        isSearchScopeChildrenAsDefault: await configManager.getConfig(
+          'customize:isSearchScopeChildrenAsDefault',
+        ),
+        isEnabledMarp: await configManager.getConfig('customize:isEnabledMarp'),
+        styleName: await configManager.getConfig('customize:highlightJsStyle'),
+        styleBorder: await configManager.getConfig(
+          'customize:highlightJsStyleBorder',
+        ),
+        customizeTitle: await configManager.getConfig('customize:title'),
+        customizeScript: await configManager.getConfig('customize:script'),
+        customizeCss: await configManager.getConfig('customize:css'),
+        customizeNoscript: await configManager.getConfig('customize:noscript'),
+      };
 
 
-    return res.apiv3({ customizeParams });
-  });
+      return res.apiv3({ customizeParams });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -317,17 +342,24 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeLayout'
    *                  $ref: '#/components/schemas/CustomizeLayout'
    */
    */
-  router.get('/layout', accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, async(req, res) => {
-    try {
-      const isContainerFluid = await configManager.getConfig('customize:isContainerFluid');
-      return res.apiv3({ isContainerFluid });
-    }
-    catch (err) {
-      const msg = 'Error occurred in getting layout';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'get-layout-failed'));
-    }
-  });
+  router.get(
+    '/layout',
+    accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      try {
+        const isContainerFluid = await configManager.getConfig(
+          'customize:isContainerFluid',
+        );
+        return res.apiv3({ isContainerFluid });
+      } catch (err) {
+        const msg = 'Error occurred in getting layout';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'get-layout-failed'));
+      }
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -356,9 +388,15 @@ module.exports = (crowi) => {
    *                      description: customized params
    *                      description: customized params
    *                      $ref: '#/components/schemas/CustomizeLayout'
    *                      $ref: '#/components/schemas/CustomizeLayout'
    */
    */
-  router.put('/layout', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.layout, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/layout',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.layout,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
       const requestParams = {
         'customize:isContainerFluid': req.body.isContainerFluid,
         'customize:isContainerFluid': req.body.isContainerFluid,
       };
       };
@@ -366,20 +404,24 @@ module.exports = (crowi) => {
       try {
       try {
         await configManager.updateConfigs(requestParams);
         await configManager.updateConfigs(requestParams);
         const customizedParams = {
         const customizedParams = {
-          isContainerFluid: await configManager.getConfig('customize:isContainerFluid'),
+          isContainerFluid: await configManager.getConfig(
+            'customize:isContainerFluid',
+          ),
         };
         };
 
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_LAYOUT_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_LAYOUT_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ customizedParams });
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating layout';
         const msg = 'Error occurred in updating layout';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-layout-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'update-layout-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -408,26 +450,31 @@ module.exports = (crowi) => {
    *                      items:
    *                      items:
    *                        $ref: '#/components/schemas/ThemesMetadata'
    *                        $ref: '#/components/schemas/ThemesMetadata'
    */
    */
-  router.get('/theme', accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]), loginRequiredStrictly, async(req, res) => {
-
-    try {
-      const currentTheme = await configManager.getConfig('customize:theme');
+  router.get(
+    '/theme',
+    accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    async (req, res) => {
+      try {
+        const currentTheme = await configManager.getConfig('customize:theme');
 
 
-      // retrieve plugin manifests
-      const themePlugins = await GrowiPlugin.findEnabledPluginsByType(GrowiPluginType.Theme);
+        // retrieve plugin manifests
+        const themePlugins = await GrowiPlugin.findEnabledPluginsByType(
+          GrowiPluginType.Theme,
+        );
 
 
-      const pluginThemesMetadatas = themePlugins
-        .map(themePlugin => themePlugin.meta.themes)
-        .flat();
+        const pluginThemesMetadatas = themePlugins.flatMap(
+          (themePlugin) => themePlugin.meta.themes,
+        );
 
 
-      return res.apiv3({ currentTheme, pluginThemesMetadatas });
-    }
-    catch (err) {
-      const msg = 'Error occurred in getting theme';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'get-theme-failed'));
-    }
-  });
+        return res.apiv3({ currentTheme, pluginThemesMetadatas });
+      } catch (err) {
+        const msg = 'Error occurred in getting theme';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'get-theme-failed'));
+      }
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -456,8 +503,15 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeTheme'
    *                      $ref: '#/components/schemas/CustomizeTheme'
    */
    */
-  router.put('/theme', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity, validator.theme, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/theme',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.theme,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
       const requestParams = {
         'customize:theme': req.body.theme,
         'customize:theme': req.body.theme,
       };
       };
@@ -468,16 +522,18 @@ module.exports = (crowi) => {
           theme: await configManager.getConfig('customize:theme'),
           theme: await configManager.getConfig('customize:theme'),
         };
         };
         customizeService.initGrowiTheme();
         customizeService.initGrowiTheme();
-        const parameters = { action: SupportedAction.ACTION_ADMIN_THEME_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_THEME_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ customizedParams });
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating theme';
         const msg = 'Error occurred in updating theme';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-theme-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'update-theme-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -497,19 +553,27 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeSidebar'
    *                  $ref: '#/components/schemas/CustomizeSidebar'
    */
    */
-  router.get('/sidebar', accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, async(req, res) => {
-
-    try {
-      const isSidebarCollapsedMode = await configManager.getConfig('customize:isSidebarCollapsedMode');
-      const isSidebarClosedAtDockMode = await configManager.getConfig('customize:isSidebarClosedAtDockMode');
-      return res.apiv3({ isSidebarCollapsedMode, isSidebarClosedAtDockMode });
-    }
-    catch (err) {
-      const msg = 'Error occurred in getting sidebar';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'get-sidebar-failed'));
-    }
-  });
+  router.get(
+    '/sidebar',
+    accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      try {
+        const isSidebarCollapsedMode = await configManager.getConfig(
+          'customize:isSidebarCollapsedMode',
+        );
+        const isSidebarClosedAtDockMode = await configManager.getConfig(
+          'customize:isSidebarClosedAtDockMode',
+        );
+        return res.apiv3({ isSidebarCollapsedMode, isSidebarClosedAtDockMode });
+      } catch (err) {
+        const msg = 'Error occurred in getting sidebar';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'get-sidebar-failed'));
+      }
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -538,31 +602,44 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeSidebar'
    *                      $ref: '#/components/schemas/CustomizeSidebar'
    */
    */
-  router.put('/sidebar', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired,
-    validator.sidebar, apiV3FormValidator, addActivity,
-    async(req, res) => {
+  router.put(
+    '/sidebar',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.sidebar,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res) => {
       const requestParams = {
       const requestParams = {
         'customize:isSidebarCollapsedMode': req.body.isSidebarCollapsedMode,
         'customize:isSidebarCollapsedMode': req.body.isSidebarCollapsedMode,
-        'customize:isSidebarClosedAtDockMode': req.body.isSidebarClosedAtDockMode,
+        'customize:isSidebarClosedAtDockMode':
+          req.body.isSidebarClosedAtDockMode,
       };
       };
 
 
       try {
       try {
         await configManager.updateConfigs(requestParams);
         await configManager.updateConfigs(requestParams);
         const customizedParams = {
         const customizedParams = {
-          isSidebarCollapsedMode: await configManager.getConfig('customize:isSidebarCollapsedMode'),
-          isSidebarClosedAtDockMode: await configManager.getConfig('customize:isSidebarClosedAtDockMode'),
+          isSidebarCollapsedMode: await configManager.getConfig(
+            'customize:isSidebarCollapsedMode',
+          ),
+          isSidebarClosedAtDockMode: await configManager.getConfig(
+            'customize:isSidebarClosedAtDockMode',
+          ),
         };
         };
 
 
-        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SIDEBAR_UPDATE });
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_ADMIN_SIDEBAR_UPDATE,
+        });
 
 
         return res.apiv3({ customizedParams });
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating sidebar';
         const msg = 'Error occurred in updating sidebar';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-sidebar-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'update-sidebar-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -591,47 +668,77 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeFunction'
    *                      $ref: '#/components/schemas/CustomizeFunction'
    */
    */
-  router.put('/function', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.function, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/function',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.function,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
       const requestParams = {
         'customize:isEnabledTimeline': req.body.isEnabledTimeline,
         'customize:isEnabledTimeline': req.body.isEnabledTimeline,
-        'customize:isEnabledAttachTitleHeader': req.body.isEnabledAttachTitleHeader,
+        'customize:isEnabledAttachTitleHeader':
+          req.body.isEnabledAttachTitleHeader,
         'customize:showPageLimitationS': req.body.pageLimitationS,
         'customize:showPageLimitationS': req.body.pageLimitationS,
         'customize:showPageLimitationM': req.body.pageLimitationM,
         'customize:showPageLimitationM': req.body.pageLimitationM,
         'customize:showPageLimitationL': req.body.pageLimitationL,
         'customize:showPageLimitationL': req.body.pageLimitationL,
         'customize:showPageLimitationXL': req.body.pageLimitationXL,
         'customize:showPageLimitationXL': req.body.pageLimitationXL,
-        'customize:isEnabledStaleNotification': req.body.isEnabledStaleNotification,
+        'customize:isEnabledStaleNotification':
+          req.body.isEnabledStaleNotification,
         'customize:isAllReplyShown': req.body.isAllReplyShown,
         'customize:isAllReplyShown': req.body.isAllReplyShown,
-        'customize:isSearchScopeChildrenAsDefault': req.body.isSearchScopeChildrenAsDefault,
+        'customize:isSearchScopeChildrenAsDefault':
+          req.body.isSearchScopeChildrenAsDefault,
         'customize:showPageSideAuthors': req.body.showPageSideAuthors,
         'customize:showPageSideAuthors': req.body.showPageSideAuthors,
       };
       };
 
 
       try {
       try {
         await configManager.updateConfigs(requestParams);
         await configManager.updateConfigs(requestParams);
         const customizedParams = {
         const customizedParams = {
-          isEnabledTimeline: await configManager.getConfig('customize:isEnabledTimeline'),
-          isEnabledAttachTitleHeader: await configManager.getConfig('customize:isEnabledAttachTitleHeader'),
-          pageLimitationS: await configManager.getConfig('customize:showPageLimitationS'),
-          pageLimitationM: await configManager.getConfig('customize:showPageLimitationM'),
-          pageLimitationL: await configManager.getConfig('customize:showPageLimitationL'),
-          pageLimitationXL: await configManager.getConfig('customize:showPageLimitationXL'),
-          isEnabledStaleNotification: await configManager.getConfig('customize:isEnabledStaleNotification'),
-          isAllReplyShown: await configManager.getConfig('customize:isAllReplyShown'),
-          isSearchScopeChildrenAsDefault: await configManager.getConfig('customize:isSearchScopeChildrenAsDefault'),
-          showPageSideAuthors: await configManager.getConfig('customize:showPageSideAuthors'),
+          isEnabledTimeline: await configManager.getConfig(
+            'customize:isEnabledTimeline',
+          ),
+          isEnabledAttachTitleHeader: await configManager.getConfig(
+            'customize:isEnabledAttachTitleHeader',
+          ),
+          pageLimitationS: await configManager.getConfig(
+            'customize:showPageLimitationS',
+          ),
+          pageLimitationM: await configManager.getConfig(
+            'customize:showPageLimitationM',
+          ),
+          pageLimitationL: await configManager.getConfig(
+            'customize:showPageLimitationL',
+          ),
+          pageLimitationXL: await configManager.getConfig(
+            'customize:showPageLimitationXL',
+          ),
+          isEnabledStaleNotification: await configManager.getConfig(
+            'customize:isEnabledStaleNotification',
+          ),
+          isAllReplyShown: await configManager.getConfig(
+            'customize:isAllReplyShown',
+          ),
+          isSearchScopeChildrenAsDefault: await configManager.getConfig(
+            'customize:isSearchScopeChildrenAsDefault',
+          ),
+          showPageSideAuthors: await configManager.getConfig(
+            'customize:showPageSideAuthors',
+          ),
+        };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE,
         };
         };
-        const parameters = { action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ customizedParams });
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating function';
         const msg = 'Error occurred in updating function';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-function-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'update-function-failed'));
       }
       }
-    });
-
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -660,9 +767,15 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizePresentation'
    *                      $ref: '#/components/schemas/CustomizePresentation'
    */
    */
-  router.put('/presentation', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.CustomizePresentation, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/presentation',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.CustomizePresentation,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
       const requestParams = {
         'customize:isEnabledMarp': req.body.isEnabledMarp,
         'customize:isEnabledMarp': req.body.isEnabledMarp,
       };
       };
@@ -670,18 +783,22 @@ module.exports = (crowi) => {
       try {
       try {
         await configManager.updateConfigs(requestParams);
         await configManager.updateConfigs(requestParams);
         const customizedParams = {
         const customizedParams = {
-          isEnabledMarp: await configManager.getConfig('customize:isEnabledMarp'),
+          isEnabledMarp: await configManager.getConfig(
+            'customize:isEnabledMarp',
+          ),
+        };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE,
         };
         };
-        const parameters = { action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ customizedParams });
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating presentaion';
         const msg = 'Error occurred in updating presentaion';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-presentation-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'update-presentation-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -710,9 +827,15 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeHighlightResponse'
    *                      $ref: '#/components/schemas/CustomizeHighlightResponse'
    */
    */
-  router.put('/highlight', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.highlight, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/highlight',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.highlight,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
       const requestParams = {
         'customize:highlightJsStyle': req.body.highlightJsStyle,
         'customize:highlightJsStyle': req.body.highlightJsStyle,
         'customize:highlightJsStyleBorder': req.body.highlightJsStyleBorder,
         'customize:highlightJsStyleBorder': req.body.highlightJsStyleBorder,
@@ -721,19 +844,25 @@ module.exports = (crowi) => {
       try {
       try {
         await configManager.updateConfigs(requestParams);
         await configManager.updateConfigs(requestParams);
         const customizedParams = {
         const customizedParams = {
-          styleName: await configManager.getConfig('customize:highlightJsStyle'),
-          styleBorder: await configManager.getConfig('customize:highlightJsStyleBorder'),
+          styleName: await configManager.getConfig(
+            'customize:highlightJsStyle',
+          ),
+          styleBorder: await configManager.getConfig(
+            'customize:highlightJsStyleBorder',
+          ),
+        };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE,
         };
         };
-        const parameters = { action: SupportedAction.ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ customizedParams });
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating highlight';
         const msg = 'Error occurred in updating highlight';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-highlight-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'update-highlight-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -762,9 +891,15 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeTitle'
    *                      $ref: '#/components/schemas/CustomizeTitle'
    */
    */
-  router.put('/customize-title', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.customizeTitle, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/customize-title',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.customizeTitle,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
       const requestParams = {
         'customize:title': req.body.customizeTitle,
         'customize:title': req.body.customizeTitle,
       };
       };
@@ -777,16 +912,18 @@ module.exports = (crowi) => {
           customizeTitle: await configManager.getConfig('customize:title'),
           customizeTitle: await configManager.getConfig('customize:title'),
         };
         };
         customizeService.initCustomTitle();
         customizeService.initCustomTitle();
-        const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_TITLE_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_CUSTOM_TITLE_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ customizedParams });
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating customizeTitle';
         const msg = 'Error occurred in updating customizeTitle';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-customizeTitle-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'update-customizeTitle-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -815,27 +952,38 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeNoscript'
    *                      $ref: '#/components/schemas/CustomizeNoscript'
    */
    */
-  router.put('/customize-noscript', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.customizeNoscript, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/customize-noscript',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.customizeNoscript,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
       const requestParams = {
         'customize:noscript': req.body.customizeNoscript,
         'customize:noscript': req.body.customizeNoscript,
       };
       };
       try {
       try {
         await configManager.updateConfigs(requestParams);
         await configManager.updateConfigs(requestParams);
         const customizedParams = {
         const customizedParams = {
-          customizeNoscript: await configManager.getConfig('customize:noscript'),
+          customizeNoscript:
+            await configManager.getConfig('customize:noscript'),
+        };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_CUSTOM_NOSCRIPT_UPDATE,
         };
         };
-        const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_NOSCRIPT_UPDATE };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ customizedParams });
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating customizeNoscript';
         const msg = 'Error occurred in updating customizeNoscript';
         logger.error('Error', err);
         logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'update-customizeNoscript-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'update-customizeNoscript-failed'),
+        );
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -864,9 +1012,15 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeCss'
    *                      $ref: '#/components/schemas/CustomizeCss'
    */
    */
-  router.put('/customize-css', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.customizeCss, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/customize-css',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.customizeCss,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
       const requestParams = {
         'customize:css': req.body.customizeCss,
         'customize:css': req.body.customizeCss,
       };
       };
@@ -878,16 +1032,18 @@ module.exports = (crowi) => {
           customizeCss: await configManager.getConfig('customize:css'),
           customizeCss: await configManager.getConfig('customize:css'),
         };
         };
         customizeService.initCustomCss();
         customizeService.initCustomCss();
-        const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_CSS_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_CUSTOM_CSS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ customizedParams });
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating customizeCss';
         const msg = 'Error occurred in updating customizeCss';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-customizeCss-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'update-customizeCss-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -916,9 +1072,15 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeScript'
    *                      $ref: '#/components/schemas/CustomizeScript'
    */
    */
-  router.put('/customize-script', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.customizeScript, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/customize-script',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.customizeScript,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
       const requestParams = {
         'customize:script': req.body.customizeScript,
         'customize:script': req.body.customizeScript,
       };
       };
@@ -927,16 +1089,18 @@ module.exports = (crowi) => {
         const customizedParams = {
         const customizedParams = {
           customizeScript: await configManager.getConfig('customize:script'),
           customizeScript: await configManager.getConfig('customize:script'),
         };
         };
-        const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ customizedParams });
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating customizeScript';
         const msg = 'Error occurred in updating customizeScript';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-customizeScript-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'update-customizeScript-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -965,12 +1129,15 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeLogo'
    *                      $ref: '#/components/schemas/CustomizeLogo'
    */
    */
-  router.put('/customize-logo', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired,
-    validator.logo, apiV3FormValidator,
-    async(req, res) => {
-      const {
-        isDefaultLogo,
-      } = req.body;
+  router.put(
+    '/customize-logo',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.logo,
+    apiV3FormValidator,
+    async (req, res) => {
+      const { isDefaultLogo } = req.body;
 
 
       const requestParams = {
       const requestParams = {
         'customize:isDefaultLogo': isDefaultLogo,
         'customize:isDefaultLogo': isDefaultLogo,
@@ -978,16 +1145,18 @@ module.exports = (crowi) => {
       try {
       try {
         await configManager.updateConfigs(requestParams);
         await configManager.updateConfigs(requestParams);
         const customizedParams = {
         const customizedParams = {
-          isDefaultLogo: await configManager.getConfig('customize:isDefaultLogo'),
+          isDefaultLogo: await configManager.getConfig(
+            'customize:isDefaultLogo',
+          ),
         };
         };
         return res.apiv3({ customizedParams });
         return res.apiv3({ customizedParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating customizeLogo';
         const msg = 'Error occurred in updating customizeLogo';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-customizeLogo-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'update-customizeLogo-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -1028,15 +1197,24 @@ module.exports = (crowi) => {
    *                            temporaryUrlExpiredAt: {}
    *                            temporaryUrlExpiredAt: {}
    *                            temporaryUrlCached: {}
    *                            temporaryUrlCached: {}
    */
    */
-  router.post('/upload-brand-logo',
-    uploads.single('file'), accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, uploads.single('file'), validator.logo, apiV3FormValidator,
-    async(req, res) => {
-
+  router.post(
+    '/upload-brand-logo',
+    uploads.single('file'),
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    uploads.single('file'),
+    validator.logo,
+    apiV3FormValidator,
+    async (req, res) => {
       if (req.file == null) {
       if (req.file == null) {
-        return res.apiv3Err(new ErrorV3('File error.', 'upload-brand-logo-failed'));
+        return res.apiv3Err(
+          new ErrorV3('File error.', 'upload-brand-logo-failed'),
+        );
       }
       }
       if (req.user == null) {
       if (req.user == null) {
-        return res.apiv3Err(new ErrorV3('param "user" must be set.', 'upload-brand-logo-failed'));
+        return res.apiv3Err(
+          new ErrorV3('param "user" must be set.', 'upload-brand-logo-failed'),
+        );
       }
       }
 
 
       const file = req.file;
       const file = req.file;
@@ -1044,27 +1222,37 @@ module.exports = (crowi) => {
       // check type
       // check type
       const acceptableFileType = /image\/.+/;
       const acceptableFileType = /image\/.+/;
       if (!file.mimetype.match(acceptableFileType)) {
       if (!file.mimetype.match(acceptableFileType)) {
-        const msg = 'File type error. Only image files is allowed to set as user picture.';
+        const msg =
+          'File type error. Only image files is allowed to set as user picture.';
         return res.apiv3Err(new ErrorV3(msg, 'upload-brand-logo-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'upload-brand-logo-failed'));
       }
       }
 
 
       // Check if previous attachment exists and remove it
       // Check if previous attachment exists and remove it
-      const attachments = await Attachment.find({ attachmentType: AttachmentType.BRAND_LOGO });
+      const attachments = await Attachment.find({
+        attachmentType: AttachmentType.BRAND_LOGO,
+      });
       if (attachments != null) {
       if (attachments != null) {
         await attachmentService.removeAllAttachments(attachments);
         await attachmentService.removeAllAttachments(attachments);
       }
       }
 
 
       let attachment;
       let attachment;
       try {
       try {
-        attachment = await attachmentService.createAttachment(file, req.user, null, AttachmentType.BRAND_LOGO);
-      }
-      catch (err) {
+        attachment = await attachmentService.createAttachment(
+          file,
+          req.user,
+          null,
+          AttachmentType.BRAND_LOGO,
+        );
+      } catch (err) {
         logger.error(err);
         logger.error(err);
-        return res.apiv3Err(new ErrorV3(err.message, 'upload-brand-logo-failed'));
+        return res.apiv3Err(
+          new ErrorV3(err.message, 'upload-brand-logo-failed'),
+        );
       }
       }
       attachment.toObject({ virtuals: true });
       attachment.toObject({ virtuals: true });
       return res.apiv3({ attachment });
       return res.apiv3({ attachment });
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -1084,24 +1272,39 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  additionalProperties: false
    *                  additionalProperties: false
    */
    */
-  router.delete('/delete-brand-logo', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.delete(
+    '/delete-brand-logo',
+    accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      const attachments = await Attachment.find({
+        attachmentType: AttachmentType.BRAND_LOGO,
+      });
 
 
-    const attachments = await Attachment.find({ attachmentType: AttachmentType.BRAND_LOGO });
-
-    if (attachments == null) {
-      return res.apiv3Err(new ErrorV3('attachment not found', 'delete-brand-logo-failed'));
-    }
+      if (attachments == null) {
+        return res.apiv3Err(
+          new ErrorV3('attachment not found', 'delete-brand-logo-failed'),
+        );
+      }
 
 
-    try {
-      await attachmentService.removeAllAttachments(attachments);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.status(500).apiv3Err(new ErrorV3('Error while deleting logo', 'delete-brand-logo-failed'));
-    }
+      try {
+        await attachmentService.removeAllAttachments(attachments);
+      } catch (err) {
+        logger.error(err);
+        return res
+          .status(500)
+          .apiv3Err(
+            new ErrorV3(
+              'Error while deleting logo',
+              'delete-brand-logo-failed',
+            ),
+          );
+      }
 
 
-    return res.apiv3({});
-  });
+      return res.apiv3({});
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 64 - 36
apps/app/src/server/routes/apiv3/export.js

@@ -1,8 +1,7 @@
-import fs from 'fs';
-
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import express from 'express';
 import express from 'express';
-import { param, body } from 'express-validator';
+import { body, param } from 'express-validator';
+import fs from 'fs';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 import sanitize from 'sanitize-filename';
 import sanitize from 'sanitize-filename';
 
 
@@ -14,7 +13,6 @@ import loggerFactory from '~/utils/logger';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:export');
 const logger = loggerFactory('growi:routes:apiv3:export');
 const router = express.Router();
 const router = express.Router();
 
 
@@ -138,7 +136,9 @@ module.exports = (crowi) => {
     socketIoService.getAdminSocket().emit('admin:onProgressForExport', data);
     socketIoService.getAdminSocket().emit('admin:onProgressForExport', data);
   });
   });
   adminEvent.on('onStartZippingForExport', (data) => {
   adminEvent.on('onStartZippingForExport', (data) => {
-    socketIoService.getAdminSocket().emit('admin:onStartZippingForExport', data);
+    socketIoService
+      .getAdminSocket()
+      .emit('admin:onStartZippingForExport', data);
   });
   });
   adminEvent.on('onTerminateForExport', (data) => {
   adminEvent.on('onTerminateForExport', (data) => {
     socketIoService.getAdminSocket().emit('admin:onTerminateForExport', data);
     socketIoService.getAdminSocket().emit('admin:onTerminateForExport', data);
@@ -155,11 +155,15 @@ module.exports = (crowi) => {
         .withMessage('"collections" array cannot be empty')
         .withMessage('"collections" array cannot be empty')
         .bail()
         .bail()
 
 
-        .custom(async(value) => {
+        .custom(async (value) => {
           // Check if all the collections in the request body exist in the database
           // Check if all the collections in the request body exist in the database
-          const listCollectionsResult = await mongoose.connection.db.listCollections().toArray();
-          const allCollectionNames = listCollectionsResult.map(collectionObj => collectionObj.name);
-          if (!value.every(v => allCollectionNames.includes(v))) {
+          const listCollectionsResult = await mongoose.connection.db
+            .listCollections()
+            .toArray();
+          const allCollectionNames = listCollectionsResult.map(
+            (collectionObj) => collectionObj.name,
+          );
+          if (!value.every((v) => allCollectionNames.includes(v))) {
             throw new Error('Invalid collections');
             throw new Error('Invalid collections');
           }
           }
         }),
         }),
@@ -167,11 +171,12 @@ module.exports = (crowi) => {
     deleteFile: [
     deleteFile: [
       // https://regex101.com/r/mD4eZs/6
       // https://regex101.com/r/mD4eZs/6
       // prevent from unexpecting attack doing delete file (path traversal attack)
       // prevent from unexpecting attack doing delete file (path traversal attack)
-      param('fileName').not().matches(/(\.\.\/|\.\.\\)/),
+      param('fileName')
+        .not()
+        .matches(/(\.\.\/|\.\.\\)/),
     ],
     ],
   };
   };
 
 
-
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -193,15 +198,21 @@ module.exports = (crowi) => {
    *                  status:
    *                  status:
    *                    $ref: '#/components/schemas/ExportStatus'
    *                    $ref: '#/components/schemas/ExportStatus'
    */
    */
-  router.get('/status', accessTokenParser([SCOPE.READ.ADMIN.EXPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
-    const status = await exportService.getStatus();
+  router.get(
+    '/status',
+    accessTokenParser([SCOPE.READ.ADMIN.EXPORT_DATA], { acceptLegacy: true }),
+    loginRequired,
+    adminRequired,
+    async (req, res) => {
+      const status = await exportService.getStatus();
 
 
-    // TODO: use res.apiv3
-    return res.json({
-      ok: true,
-      status,
-    });
-  });
+      // TODO: use res.apiv3
+      return res.json({
+        ok: true,
+        status,
+      });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -233,28 +244,37 @@ module.exports = (crowi) => {
    *                    type: boolean
    *                    type: boolean
    *                    description: whether the request is succeeded
    *                    description: whether the request is succeeded
    */
    */
-  router.post('/', accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired,
-    validator.generateZipFile, apiV3FormValidator, addActivity, async(req, res) => {
-    // TODO: add express validator
+  router.post(
+    '/',
+    accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }),
+    loginRequired,
+    adminRequired,
+    validator.generateZipFile,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res) => {
+      // TODO: add express validator
       try {
       try {
         const { collections } = req.body;
         const { collections } = req.body;
 
 
         exportService.export(collections);
         exportService.export(collections);
 
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_CREATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_CREATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         // TODO: use res.apiv3
         // TODO: use res.apiv3
         return res.status(200).json({
         return res.status(200).json({
           ok: true,
           ok: true,
         });
         });
-      }
-      catch (err) {
-      // TODO: use ApiV3Error
+      } catch (err) {
+        // TODO: use ApiV3Error
         logger.error(err);
         logger.error(err);
         return res.status(500).send({ status: 'ERROR' });
         return res.status(500).send({ status: 'ERROR' });
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -283,28 +303,36 @@ module.exports = (crowi) => {
    *                    type: boolean
    *                    type: boolean
    *                    description: whether the request is succeeded
    *                    description: whether the request is succeeded
    */
    */
-  router.delete('/:fileName', accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired,
-    validator.deleteFile, apiV3FormValidator, addActivity,
-    async(req, res) => {
-    // TODO: add express validator
+  router.delete(
+    '/:fileName',
+    accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }),
+    loginRequired,
+    adminRequired,
+    validator.deleteFile,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res) => {
+      // TODO: add express validator
       const { fileName } = req.params;
       const { fileName } = req.params;
 
 
       try {
       try {
         const sanitizedFileName = sanitize(fileName);
         const sanitizedFileName = sanitize(fileName);
         const zipFile = exportService.getFile(sanitizedFileName);
         const zipFile = exportService.getFile(sanitizedFileName);
         fs.unlinkSync(zipFile);
         fs.unlinkSync(zipFile);
-        const parameters = { action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_DELETE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_DELETE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         // TODO: use res.apiv3
         // TODO: use res.apiv3
         return res.status(200).send({ ok: true });
         return res.status(200).send({ ok: true });
-      }
-      catch (err) {
-      // TODO: use ApiV3Error
+      } catch (err) {
+        // TODO: use ApiV3Error
         logger.error(err);
         logger.error(err);
         return res.status(500).send({ ok: false });
         return res.status(500).send({ ok: false });
       }
       }
-    });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 110 - 62
apps/app/src/server/routes/apiv3/forgot-password.js

@@ -37,7 +37,7 @@ const { body } = require('express-validator');
  *           type: string
  *           type: string
  *         error:
  *         error:
  *           type: string
  *           type: string
-*/
+ */
 
 
 const router = express.Router();
 const router = express.Router();
 
 
@@ -55,13 +55,21 @@ module.exports = (crowi) => {
 
 
   const validator = {
   const validator = {
     password: [
     password: [
-      body('newPassword').isString().not().isEmpty()
+      body('newPassword')
+        .isString()
+        .not()
+        .isEmpty()
         .isLength({ min: minPasswordLength })
         .isLength({ min: minPasswordLength })
-        .withMessage(`password must be at least ${minPasswordLength} characters long`),
+        .withMessage(
+          `password must be at least ${minPasswordLength} characters long`,
+        ),
       // checking if password confirmation matches password
       // checking if password confirmation matches password
-      body('newPasswordConfirm').isString().not().isEmpty()
+      body('newPasswordConfirm')
+        .isString()
+        .not()
+        .isEmpty()
         .custom((value, { req }) => {
         .custom((value, { req }) => {
-          return (value === req.body.newPassword);
+          return value === req.body.newPassword;
         }),
         }),
     ],
     ],
     email: [
     email: [
@@ -74,13 +82,23 @@ module.exports = (crowi) => {
     ],
     ],
   };
   };
 
 
-  const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
+  const checkPassportStrategyMiddleware =
+    checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
 
 
-  async function sendPasswordResetEmail(templateFileName, locale, email, url, expiredAt) {
+  async function sendPasswordResetEmail(
+    templateFileName,
+    locale,
+    email,
+    url,
+    expiredAt,
+  ) {
     return mailService.send({
     return mailService.send({
       to: email,
       to: email,
       subject: '[GROWI] Password Reset',
       subject: '[GROWI] Password Reset',
-      template: path.join(crowi.localeDir, `${locale}/notifications/${templateFileName}.ejs`),
+      template: path.join(
+        crowi.localeDir,
+        `${locale}/notifications/${templateFileName}.ejs`,
+      ),
       vars: {
       vars: {
         appTitle: appService.getAppTitle(),
         appTitle: appService.getAppTitle(),
         email,
         email,
@@ -118,39 +136,60 @@ module.exports = (crowi) => {
    *              schema:
    *              schema:
    *                type: object
    *                type: object
    */
    */
-  router.post('/', checkPassportStrategyMiddleware, validator.email, apiV3FormValidator, addActivity, async(req, res) => {
-    const { email } = req.body;
-    const locale = configManager.getConfig('app:globalLang');
-    const appUrl = growiInfoService.getSiteUrl();
+  router.post(
+    '/',
+    checkPassportStrategyMiddleware,
+    validator.email,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res) => {
+      const { email } = req.body;
+      const locale = configManager.getConfig('app:globalLang');
+      const appUrl = growiInfoService.getSiteUrl();
 
 
-    try {
-      const user = await User.findOne({ email });
+      try {
+        const user = await User.findOne({ email });
 
 
-      // when the user is not found or active
-      if (user == null || user.status !== 2) {
-        // Do not send emails to non GROWI user
-        // For security reason, do not use error messages like "Email does not exist"
-        return res.apiv3();
-      }
+        // when the user is not found or active
+        if (user == null || user.status !== 2) {
+          // Do not send emails to non GROWI user
+          // For security reason, do not use error messages like "Email does not exist"
+          return res.apiv3();
+        }
 
 
-      const passwordResetOrderData = await PasswordResetOrder.createPasswordResetOrder(email);
-      const url = new URL(`/forgot-password/${passwordResetOrderData.token}`, appUrl);
-      const oneTimeUrl = url.href;
-      const grwTzoffsetSec = crowi.appService.getTzoffset() * 60;
-      const expiredAt = subSeconds(passwordResetOrderData.expiredAt, grwTzoffsetSec);
-      const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
-      await sendPasswordResetEmail('passwordReset', locale, email, oneTimeUrl, formattedExpiredAt);
+        const passwordResetOrderData =
+          await PasswordResetOrder.createPasswordResetOrder(email);
+        const url = new URL(
+          `/forgot-password/${passwordResetOrderData.token}`,
+          appUrl,
+        );
+        const oneTimeUrl = url.href;
+        const grwTzoffsetSec = crowi.appService.getTzoffset() * 60;
+        const expiredAt = subSeconds(
+          passwordResetOrderData.expiredAt,
+          grwTzoffsetSec,
+        );
+        const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
+        await sendPasswordResetEmail(
+          'passwordReset',
+          locale,
+          email,
+          oneTimeUrl,
+          formattedExpiredAt,
+        );
 
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_USER_FOGOT_PASSWORD });
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_USER_FOGOT_PASSWORD,
+        });
 
 
-      return res.apiv3();
-    }
-    catch (err) {
-      const msg = 'Error occurred during password reset request procedure.';
-      logger.error(err);
-      return res.apiv3Err(`${msg} Cause: ${err}`);
-    }
-  });
+        return res.apiv3();
+      } catch (err) {
+        const msg = 'Error occurred during password reset request procedure.';
+        logger.error(err);
+        return res.apiv3Err(`${msg} Cause: ${err}`);
+      }
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -184,35 +223,44 @@ module.exports = (crowi) => {
    *                    $ref: '#/components/schemas/User'
    *                    $ref: '#/components/schemas/User'
    */
    */
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  router.put('/', checkPassportStrategyMiddleware, injectResetOrderByTokenMiddleware, validator.password, apiV3FormValidator, addActivity, async(req, res) => {
-    const { passwordResetOrder } = req;
-    const { email } = passwordResetOrder;
-    const grobalLang = configManager.getConfig('app:globalLang');
-    const i18n = grobalLang || req.language;
-    const { newPassword } = req.body;
-
-    const user = await User.findOne({ email });
-
-    // when the user is not found or active
-    if (user == null || user.status !== 2) {
-      return res.apiv3Err('update-password-failed');
-    }
+  router.put(
+    '/',
+    checkPassportStrategyMiddleware,
+    injectResetOrderByTokenMiddleware,
+    validator.password,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res) => {
+      const { passwordResetOrder } = req;
+      const { email } = passwordResetOrder;
+      const grobalLang = configManager.getConfig('app:globalLang');
+      const i18n = grobalLang || req.language;
+      const { newPassword } = req.body;
 
 
-    try {
-      const userData = await user.updatePassword(newPassword);
-      const serializedUserData = serializeUserSecurely(userData);
-      passwordResetOrder.revokeOneTimeToken();
-      await sendPasswordResetEmail('passwordResetSuccessful', i18n, email);
+      const user = await User.findOne({ email });
 
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_USER_RESET_PASSWORD });
+      // when the user is not found or active
+      if (user == null || user.status !== 2) {
+        return res.apiv3Err('update-password-failed');
+      }
 
 
-      return res.apiv3({ userData: serializedUserData });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('update-password-failed');
-    }
-  });
+      try {
+        const userData = await user.updatePassword(newPassword);
+        const serializedUserData = serializeUserSecurely(userData);
+        passwordResetOrder.revokeOneTimeToken();
+        await sendPasswordResetEmail('passwordResetSuccessful', i18n, email);
+
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_USER_RESET_PASSWORD,
+        });
+
+        return res.apiv3({ userData: serializedUserData });
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err('update-password-failed');
+      }
+    },
+  );
 
 
   // middleware to handle error
   // middleware to handle error
   router.use(httpErrorHandler);
   router.use(httpErrorHandler);

+ 79 - 24
apps/app/src/server/routes/apiv3/index.js

@@ -7,7 +7,6 @@ import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import injectUserRegistrationOrderByTokenMiddleware from '../../middlewares/inject-user-registration-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../../middlewares/inject-user-registration-order-by-token-middleware';
 import * as loginFormValidator from '../../middlewares/login-form-validator';
 import * as loginFormValidator from '../../middlewares/login-form-validator';
 import * as registerFormValidator from '../../middlewares/register-form-validator';
 import * as registerFormValidator from '../../middlewares/register-form-validator';
-
 import g2gTransfer from './g2g-transfer';
 import g2gTransfer from './g2g-transfer';
 import importRoute from './import';
 import importRoute from './import';
 import pageListing from './page-listing';
 import pageListing from './page-listing';
@@ -26,7 +25,9 @@ const routerForAuth = express.Router();
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi, app) => {
 module.exports = (crowi, app) => {
   const isInstalled = crowi.configManager.getConfig('app:installed');
   const isInstalled = crowi.configManager.getConfig('app:installed');
-  const minPasswordLength = crowi.configManager.getConfig('app:minPasswordLength');
+  const minPasswordLength = crowi.configManager.getConfig(
+    'app:minPasswordLength',
+  );
 
 
   // add custom functions to express response
   // add custom functions to express response
   require('./response')(express, crowi);
   require('./response')(express, crowi);
@@ -37,45 +38,85 @@ module.exports = (crowi, app) => {
   routerForAdmin.use('/admin-home', require('./admin-home')(crowi));
   routerForAdmin.use('/admin-home', require('./admin-home')(crowi));
   routerForAdmin.use('/markdown-setting', require('./markdown-setting')(crowi));
   routerForAdmin.use('/markdown-setting', require('./markdown-setting')(crowi));
   routerForAdmin.use('/app-settings', require('./app-settings')(crowi));
   routerForAdmin.use('/app-settings', require('./app-settings')(crowi));
-  routerForAdmin.use('/customize-setting', require('./customize-setting')(crowi));
-  routerForAdmin.use('/notification-setting', require('./notification-setting')(crowi));
+  routerForAdmin.use(
+    '/customize-setting',
+    require('./customize-setting')(crowi),
+  );
+  routerForAdmin.use(
+    '/notification-setting',
+    require('./notification-setting')(crowi),
+  );
   routerForAdmin.use('/users', require('./users')(crowi));
   routerForAdmin.use('/users', require('./users')(crowi));
   routerForAdmin.use('/user-groups', require('./user-group')(crowi));
   routerForAdmin.use('/user-groups', require('./user-group')(crowi));
-  routerForAdmin.use('/external-user-groups', require('~/features/external-user-group/server/routes/apiv3/external-user-group')(crowi));
+  routerForAdmin.use(
+    '/external-user-groups',
+    require('~/features/external-user-group/server/routes/apiv3/external-user-group')(
+      crowi,
+    ),
+  );
   routerForAdmin.use('/export', require('./export')(crowi));
   routerForAdmin.use('/export', require('./export')(crowi));
   routerForAdmin.use('/import', importRoute(crowi));
   routerForAdmin.use('/import', importRoute(crowi));
   routerForAdmin.use('/search', require('./search')(crowi));
   routerForAdmin.use('/search', require('./search')(crowi));
   routerForAdmin.use('/security-setting', securitySettings(crowi));
   routerForAdmin.use('/security-setting', securitySettings(crowi));
   routerForAdmin.use('/mongo', require('./mongo')(crowi));
   routerForAdmin.use('/mongo', require('./mongo')(crowi));
-  routerForAdmin.use('/slack-integration-settings', require('./slack-integration-settings')(crowi));
-  routerForAdmin.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
+  routerForAdmin.use(
+    '/slack-integration-settings',
+    require('./slack-integration-settings')(crowi),
+  );
+  routerForAdmin.use(
+    '/slack-integration-legacy-settings',
+    require('./slack-integration-legacy-settings')(crowi),
+  );
   routerForAdmin.use('/activity', require('./activity')(crowi));
   routerForAdmin.use('/activity', require('./activity')(crowi));
   routerForAdmin.use('/g2g-transfer', g2gTransfer(crowi));
   routerForAdmin.use('/g2g-transfer', g2gTransfer(crowi));
   routerForAdmin.use('/plugins', growiPlugin(crowi));
   routerForAdmin.use('/plugins', growiPlugin(crowi));
 
 
   // auth
   // auth
-  const applicationInstalled = require('../../middlewares/application-installed')(crowi);
+  const applicationInstalled =
+    require('../../middlewares/application-installed')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
   const login = require('../login')(crowi, app);
   const login = require('../login')(crowi, app);
   const loginPassport = require('../login-passport')(crowi, app);
   const loginPassport = require('../login-passport')(crowi, app);
 
 
-  routerForAuth.post('/login', applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation,
-    addActivity, loginPassport.injectRedirectTo, loginPassport.isEnableLoginWithLocalOrLdap, loginPassport.loginWithLocal, loginPassport.loginWithLdap,
-    loginPassport.cannotLoginErrorHadnler, loginPassport.loginFailure);
+  routerForAuth.post(
+    '/login',
+    applicationInstalled,
+    loginFormValidator.loginRules(),
+    loginFormValidator.loginValidation,
+    addActivity,
+    loginPassport.injectRedirectTo,
+    loginPassport.isEnableLoginWithLocalOrLdap,
+    loginPassport.loginWithLocal,
+    loginPassport.loginWithLdap,
+    loginPassport.cannotLoginErrorHadnler,
+    loginPassport.loginFailure,
+  );
 
 
   routerForAuth.use('/invited', require('./invited')(crowi));
   routerForAuth.use('/invited', require('./invited')(crowi));
   routerForAuth.use('/logout', require('./logout')(crowi));
   routerForAuth.use('/logout', require('./logout')(crowi));
 
 
-  routerForAuth.post('/register',
-    applicationInstalled, registerFormValidator.registerRules(minPasswordLength), registerFormValidator.registerValidation, addActivity, login.register);
+  routerForAuth.post(
+    '/register',
+    applicationInstalled,
+    registerFormValidator.registerRules(minPasswordLength),
+    registerFormValidator.registerValidation,
+    addActivity,
+    login.register,
+  );
 
 
-  routerForAuth.post('/user-activation/register', applicationInstalled, userActivation.registerRules(minPasswordLength),
-    userActivation.validateRegisterForm, userActivation.registerAction(crowi));
+  routerForAuth.post(
+    '/user-activation/register',
+    applicationInstalled,
+    userActivation.registerRules(minPasswordLength),
+    userActivation.validateRegisterForm,
+    userActivation.registerAction(crowi),
+  );
 
 
   // installer
   // installer
-  routerForAdmin.use('/installer', isInstalled
-    ? allreadyInstalledMiddleware
-    : require('./installer')(crowi));
+  routerForAdmin.use(
+    '/installer',
+    isInstalled ? allreadyInstalledMiddleware : require('./installer')(crowi),
+  );
 
 
   if (!isInstalled) {
   if (!isInstalled) {
     return [router, routerForAdmin, routerForAuth];
     return [router, routerForAdmin, routerForAuth];
@@ -87,11 +128,15 @@ module.exports = (crowi, app) => {
   router.use('/user-activities', require('./user-activities')(crowi));
   router.use('/user-activities', require('./user-activities')(crowi));
 
 
   router.use('/user-group-relations', require('./user-group-relation')(crowi));
   router.use('/user-group-relations', require('./user-group-relation')(crowi));
-  router.use('/external-user-group-relations', require('~/features/external-user-group/server/routes/apiv3/external-user-group-relation')(crowi));
+  router.use(
+    '/external-user-group-relations',
+    require('~/features/external-user-group/server/routes/apiv3/external-user-group-relation')(
+      crowi,
+    ),
+  );
 
 
   router.use('/statistics', require('./statistics')(crowi));
   router.use('/statistics', require('./statistics')(crowi));
 
 
-
   router.use('/search', require('./search')(crowi));
   router.use('/search', require('./search')(crowi));
 
 
   router.use('/page', require('./page')(crowi));
   router.use('/page', require('./page')(crowi));
@@ -114,18 +159,28 @@ module.exports = (crowi, app) => {
   const user = require('../user')(crowi, null);
   const user = require('../user')(crowi, null);
   router.get('/check-username', user.api.checkUsername);
   router.get('/check-username', user.api.checkUsername);
 
 
-  router.post('/complete-registration',
+  router.post(
+    '/complete-registration',
     addActivity,
     addActivity,
     injectUserRegistrationOrderByTokenMiddleware,
     injectUserRegistrationOrderByTokenMiddleware,
     userActivation.completeRegistrationRules(),
     userActivation.completeRegistrationRules(),
     userActivation.validateCompleteRegistration,
     userActivation.validateCompleteRegistration,
-    userActivation.completeRegistrationAction(crowi));
+    userActivation.completeRegistrationAction(crowi),
+  );
 
 
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
 
   router.use('/bookmark-folder', require('./bookmark-folder')(crowi));
   router.use('/bookmark-folder', require('./bookmark-folder')(crowi));
-  router.use('/templates', require('~/features/templates/server/routes/apiv3')(crowi));
-  router.use('/page-bulk-export', require('~/features/page-bulk-export/server/routes/apiv3/page-bulk-export')(crowi));
+  router.use(
+    '/templates',
+    require('~/features/templates/server/routes/apiv3')(crowi),
+  );
+  router.use(
+    '/page-bulk-export',
+    require('~/features/page-bulk-export/server/routes/apiv3/page-bulk-export')(
+      crowi,
+    ),
+  );
 
 
   router.use('/openai', openaiRouteFactory(crowi));
   router.use('/openai', openaiRouteFactory(crowi));
 
 

+ 1 - 2
apps/app/src/server/routes/apiv3/logout.js

@@ -2,7 +2,6 @@ import { SupportedAction } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:logout'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:logout'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
@@ -29,7 +28,7 @@ module.exports = (crowi) => {
    *        500:
    *        500:
    *          description: Internal server error
    *          description: Internal server error
    */
    */
-  router.post('/', addActivity, async(req, res) => {
+  router.post('/', addActivity, async (req, res) => {
     req.session.destroy();
     req.session.destroy();
 
 
     const activityId = res.locals.activity._id;
     const activityId = res.locals.activity._id;

+ 110 - 49
apps/app/src/server/routes/apiv3/markdown-setting.js

@@ -33,7 +33,6 @@ const validator = {
   ],
   ],
 };
 };
 
 
-
 /**
 /**
  * @swagger
  * @swagger
  *
  *
@@ -123,7 +122,9 @@ const validator = {
 
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
 
@@ -150,20 +151,42 @@ module.exports = (crowi) => {
    *                      description: markdown params
    *                      description: markdown params
    *                      $ref: '#/components/schemas/MarkdownParams'
    *                      $ref: '#/components/schemas/MarkdownParams'
    */
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.MARKDOWN]), loginRequiredStrictly, adminRequired, async(req, res) => {
-    const markdownParams = {
-      isEnabledLinebreaks: await crowi.configManager.getConfig('markdown:isEnabledLinebreaks'),
-      isEnabledLinebreaksInComments: await crowi.configManager.getConfig('markdown:isEnabledLinebreaksInComments'),
-      adminPreferredIndentSize: await crowi.configManager.getConfig('markdown:adminPreferredIndentSize'),
-      isIndentSizeForced: await crowi.configManager.getConfig('markdown:isIndentSizeForced'),
-      isEnabledXss: await crowi.configManager.getConfig('markdown:rehypeSanitize:isEnabledPrevention'),
-      xssOption: await crowi.configManager.getConfig('markdown:rehypeSanitize:option'),
-      tagWhitelist: await crowi.configManager.getConfig('markdown:rehypeSanitize:tagNames'),
-      attrWhitelist: await crowi.configManager.getConfig('markdown:rehypeSanitize:attributes'),
-    };
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.MARKDOWN]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      const markdownParams = {
+        isEnabledLinebreaks: await crowi.configManager.getConfig(
+          'markdown:isEnabledLinebreaks',
+        ),
+        isEnabledLinebreaksInComments: await crowi.configManager.getConfig(
+          'markdown:isEnabledLinebreaksInComments',
+        ),
+        adminPreferredIndentSize: await crowi.configManager.getConfig(
+          'markdown:adminPreferredIndentSize',
+        ),
+        isIndentSizeForced: await crowi.configManager.getConfig(
+          'markdown:isIndentSizeForced',
+        ),
+        isEnabledXss: await crowi.configManager.getConfig(
+          'markdown:rehypeSanitize:isEnabledPrevention',
+        ),
+        xssOption: await crowi.configManager.getConfig(
+          'markdown:rehypeSanitize:option',
+        ),
+        tagWhitelist: await crowi.configManager.getConfig(
+          'markdown:rehypeSanitize:tagNames',
+        ),
+        attrWhitelist: await crowi.configManager.getConfig(
+          'markdown:rehypeSanitize:attributes',
+        ),
+      };
 
 
-    return res.apiv3({ markdownParams });
-  });
+      return res.apiv3({ markdownParams });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -192,33 +215,45 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      $ref: '#/components/schemas/LineBreakParams'
    *                      $ref: '#/components/schemas/LineBreakParams'
    */
    */
-  router.put('/lineBreak', accessTokenParser([SCOPE.WRITE.ADMIN.MARKDOWN]),
-    loginRequiredStrictly, adminRequired, addActivity, validator.lineBreak, apiV3FormValidator, async(req, res) => {
-
+  router.put(
+    '/lineBreak',
+    accessTokenParser([SCOPE.WRITE.ADMIN.MARKDOWN]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.lineBreak,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestLineBreakParams = {
       const requestLineBreakParams = {
         'markdown:isEnabledLinebreaks': req.body.isEnabledLinebreaks,
         'markdown:isEnabledLinebreaks': req.body.isEnabledLinebreaks,
-        'markdown:isEnabledLinebreaksInComments': req.body.isEnabledLinebreaksInComments,
+        'markdown:isEnabledLinebreaksInComments':
+          req.body.isEnabledLinebreaksInComments,
       };
       };
 
 
       try {
       try {
         await configManager.updateConfigs(requestLineBreakParams);
         await configManager.updateConfigs(requestLineBreakParams);
         const lineBreaksParams = {
         const lineBreaksParams = {
-          isEnabledLinebreaks: await crowi.configManager.getConfig('markdown:isEnabledLinebreaks'),
-          isEnabledLinebreaksInComments: await crowi.configManager.getConfig('markdown:isEnabledLinebreaksInComments'),
+          isEnabledLinebreaks: await crowi.configManager.getConfig(
+            'markdown:isEnabledLinebreaks',
+          ),
+          isEnabledLinebreaksInComments: await crowi.configManager.getConfig(
+            'markdown:isEnabledLinebreaksInComments',
+          ),
         };
         };
 
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ lineBreaksParams });
         return res.apiv3({ lineBreaksParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating lineBreak';
         const msg = 'Error occurred in updating lineBreak';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-lineBreak-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'update-lineBreak-failed'));
       }
       }
-
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -248,9 +283,15 @@ module.exports = (crowi) => {
    *                      description: indent params
    *                      description: indent params
    *                      $ref: '#/components/schemas/IndentParams'
    *                      $ref: '#/components/schemas/IndentParams'
    */
    */
-  router.put('/indent', accessTokenParser([SCOPE.WRITE.ADMIN.MARKDOWN]),
-    loginRequiredStrictly, adminRequired, addActivity, validator.indent, apiV3FormValidator, async(req, res) => {
-
+  router.put(
+    '/indent',
+    accessTokenParser([SCOPE.WRITE.ADMIN.MARKDOWN]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.indent,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestIndentParams = {
       const requestIndentParams = {
         'markdown:adminPreferredIndentSize': req.body.adminPreferredIndentSize,
         'markdown:adminPreferredIndentSize': req.body.adminPreferredIndentSize,
         'markdown:isIndentSizeForced': req.body.isIndentSizeForced,
         'markdown:isIndentSizeForced': req.body.isIndentSizeForced,
@@ -259,22 +300,27 @@ module.exports = (crowi) => {
       try {
       try {
         await configManager.updateConfigs(requestIndentParams);
         await configManager.updateConfigs(requestIndentParams);
         const indentParams = {
         const indentParams = {
-          adminPreferredIndentSize: await crowi.configManager.getConfig('markdown:adminPreferredIndentSize'),
-          isIndentSizeForced: await crowi.configManager.getConfig('markdown:isIndentSizeForced'),
+          adminPreferredIndentSize: await crowi.configManager.getConfig(
+            'markdown:adminPreferredIndentSize',
+          ),
+          isIndentSizeForced: await crowi.configManager.getConfig(
+            'markdown:isIndentSizeForced',
+          ),
         };
         };
 
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_INDENT_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_MARKDOWN_INDENT_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ indentParams });
         return res.apiv3({ indentParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating indent';
         const msg = 'Error occurred in updating indent';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-indent-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'update-indent-failed'));
       }
       }
-
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -300,16 +346,22 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/XssParams'
    *                  $ref: '#/components/schemas/XssParams'
    */
    */
-  router.put('/xss', accessTokenParser([SCOPE.WRITE.ADMIN.MARKDOWN]),
-    loginRequiredStrictly, adminRequired, addActivity, validator.xssSetting, apiV3FormValidator, async(req, res) => {
+  router.put(
+    '/xss',
+    accessTokenParser([SCOPE.WRITE.ADMIN.MARKDOWN]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.xssSetting,
+    apiV3FormValidator,
+    async (req, res) => {
       if (req.body.isEnabledXss && req.body.xssOption == null) {
       if (req.body.isEnabledXss && req.body.xssOption == null) {
         return res.apiv3Err(new ErrorV3('xss option is required'));
         return res.apiv3Err(new ErrorV3('xss option is required'));
       }
       }
 
 
       try {
       try {
         JSON.parse(req.body.attrWhitelist);
         JSON.parse(req.body.attrWhitelist);
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating xss';
         const msg = 'Error occurred in updating xss';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-xss-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'update-xss-failed'));
@@ -325,24 +377,33 @@ module.exports = (crowi) => {
       try {
       try {
         await configManager.updateConfigs(reqestXssParams);
         await configManager.updateConfigs(reqestXssParams);
         const xssParams = {
         const xssParams = {
-          isEnabledXss: await crowi.configManager.getConfig('markdown:rehypeSanitize:isEnabledPrevention'),
-          xssOption: await crowi.configManager.getConfig('markdown:rehypeSanitize:option'),
-          tagWhitelist: await crowi.configManager.getConfig('markdown:rehypeSanitize:tagNames'),
-          attrWhitelist: await crowi.configManager.getConfig('markdown:rehypeSanitize:attributes'),
+          isEnabledXss: await crowi.configManager.getConfig(
+            'markdown:rehypeSanitize:isEnabledPrevention',
+          ),
+          xssOption: await crowi.configManager.getConfig(
+            'markdown:rehypeSanitize:option',
+          ),
+          tagWhitelist: await crowi.configManager.getConfig(
+            'markdown:rehypeSanitize:tagNames',
+          ),
+          attrWhitelist: await crowi.configManager.getConfig(
+            'markdown:rehypeSanitize:attributes',
+          ),
         };
         };
 
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_XSS_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_MARKDOWN_XSS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ xssParams });
         return res.apiv3({ xssParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating xss';
         const msg = 'Error occurred in updating xss';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-xss-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'update-xss-failed'));
       }
       }
-
-    });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 23 - 11
apps/app/src/server/routes/apiv3/mongo.js

@@ -12,7 +12,9 @@ const router = express.Router();
 
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
 
 
   /**
   /**
@@ -38,16 +40,26 @@ module.exports = (crowi) => {
    *                    items:
    *                    items:
    *                      type: string
    *                      type: string
    */
    */
-  router.get('/collections', accessTokenParser([SCOPE.READ.ADMIN.EXPORT_DATA]), loginRequiredStrictly, adminRequired, async(req, res) => {
-    const listCollectionsResult = await mongoose.connection.db.listCollections().toArray();
-    const collections = listCollectionsResult.map(collectionObj => collectionObj.name);
-
-    // TODO: use res.apiv3
-    return res.json({
-      ok: true,
-      collections,
-    });
-  });
+  router.get(
+    '/collections',
+    accessTokenParser([SCOPE.READ.ADMIN.EXPORT_DATA]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      const listCollectionsResult = await mongoose.connection.db
+        .listCollections()
+        .toArray();
+      const collections = listCollectionsResult.map(
+        (collectionObj) => collectionObj.name,
+      );
+
+      // TODO: use res.apiv3
+      return res.json({
+        ok: true,
+        collections,
+      });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 243 - 176
apps/app/src/server/routes/apiv3/notification-setting.js

@@ -1,8 +1,8 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import express from 'express';
 import express from 'express';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { GlobalNotificationSettingType } from '~/server/models/GlobalNotificationSetting';
 import { GlobalNotificationSettingType } from '~/server/models/GlobalNotificationSetting';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
@@ -13,11 +13,9 @@ import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import UpdatePost from '../../models/update-post';
 import UpdatePost from '../../models/update-post';
 
 
-
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:notification-setting');
 const logger = loggerFactory('growi:routes:apiv3:notification-setting');
 
 
-
 const router = express.Router();
 const router = express.Router();
 
 
 const { body } = require('express-validator');
 const { body } = require('express-validator');
@@ -28,19 +26,28 @@ const validator = {
     body('channel').isString().trim(),
     body('channel').isString().trim(),
   ],
   ],
   globalNotification: [
   globalNotification: [
-    body('triggerPath').isString().trim().not()
-      .isEmpty(),
+    body('triggerPath').isString().trim().not().isEmpty(),
     body('notifyType').isString().trim().isIn(['mail', 'slack']),
     body('notifyType').isString().trim().isIn(['mail', 'slack']),
-    body('toEmail').trim().custom((value, { req }) => {
-      return (req.body.notifyType === 'mail') ? (!!value && value.match(/.+@.+\..+/)) : true;
-    }),
-    body('slackChannels').trim().custom((value, { req }) => {
-      return (req.body.notifyType === 'slack') ? !!value : true;
-    }),
+    body('toEmail')
+      .trim()
+      .custom((value, { req }) => {
+        return req.body.notifyType === 'mail'
+          ? !!value && value.match(/.+@.+\..+/)
+          : true;
+      }),
+    body('slackChannels')
+      .trim()
+      .custom((value, { req }) => {
+        return req.body.notifyType === 'slack' ? !!value : true;
+      }),
   ],
   ],
   notifyForPageGrant: [
   notifyForPageGrant: [
-    body('isNotificationForOwnerPageEnabled').if(value => value != null).isBoolean(),
-    body('isNotificationForGroupPageEnabled').if(value => value != null).isBoolean(),
+    body('isNotificationForOwnerPageEnabled')
+      .if((value) => value != null)
+      .isBoolean(),
+    body('isNotificationForGroupPageEnabled')
+      .if((value) => value != null)
+      .isBoolean(),
   ],
   ],
 };
 };
 
 
@@ -184,8 +191,10 @@ module.exports = (crowi) => {
 
 
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
 
 
-  const GlobalNotificationMailSetting = crowi.models.GlobalNotificationMailSetting;
-  const GlobalNotificationSlackSetting = crowi.models.GlobalNotificationSlackSetting;
+  const GlobalNotificationMailSetting =
+    crowi.models.GlobalNotificationMailSetting;
+  const GlobalNotificationSlackSetting =
+    crowi.models.GlobalNotificationSlackSetting;
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -208,80 +217,106 @@ module.exports = (crowi) => {
    *                      description: notification params
    *                      description: notification params
    *                      $ref: '#/components/schemas/NotificationParams'
    *                      $ref: '#/components/schemas/NotificationParams'
    */
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.EXTERNAL_NOTIFICATION]), Strictly, adminRequired, async(req, res) => {
-
-    const notificationParams = {
-      // status of slack intagration
-      isSlackbotConfigured: crowi.slackIntegrationService.isSlackbotConfigured,
-      isSlackLegacyConfigured: crowi.slackIntegrationService.isSlackLegacyConfigured,
-      currentBotType: await crowi.configManager.getConfig('slackbot:currentBotType'),
-
-      userNotifications: await UpdatePost.findAll(),
-      isNotificationForOwnerPageEnabled: await crowi.configManager.getConfig('notification:owner-page:isEnabled'),
-      isNotificationForGroupPageEnabled: await crowi.configManager.getConfig('notification:group-page:isEnabled'),
-      globalNotifications: await GlobalNotificationSetting.findAll(),
-    };
-    return res.apiv3({ notificationParams });
-  });
-
-  /**
-  * @swagger
-  *
-  *    /notification-setting/user-notification:
-  *      post:
-  *        tags: [NotificationSetting]
-  *        security:
-  *         - cookieAuth: []
-  *        description: add user notification setting
-  *        requestBody:
-  *          required: true
-  *          content:
-  *            application/json:
-  *              schema:
-  *                $ref: '#/components/schemas/UserNotificationParams'
-  *        responses:
-  *          200:
-  *            description: Succeeded to add user notification setting
-  *            content:
-  *              application/json:
-  *                schema:
-  *                  properties:
-  *                    responseParams:
-  *                      type: object
-  *                      description: response params
-  *                      properties:
-  *                        createdUser:
-  *                          $ref: '#/components/schemas/User'
-  *                          description: user who set notification
-  *                        userNotifications:
-  *                          type: array
-  *                          items:
-  *                            $ref: '#/components/schemas/UserNotification'
-  *                            description: user notification settings
-  */
-  // eslint-disable-next-line max-len
-  router.post('/user-notification', accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]), Strictly, adminRequired, addActivity, validator.userNotification, apiV3FormValidator, async(req, res) => {
-    const { pathPattern, channel } = req.body;
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.EXTERNAL_NOTIFICATION]),
+    Strictly,
+    adminRequired,
+    async (req, res) => {
+      const notificationParams = {
+        // status of slack intagration
+        isSlackbotConfigured:
+          crowi.slackIntegrationService.isSlackbotConfigured,
+        isSlackLegacyConfigured:
+          crowi.slackIntegrationService.isSlackLegacyConfigured,
+        currentBotType: await crowi.configManager.getConfig(
+          'slackbot:currentBotType',
+        ),
 
 
-    try {
-      logger.info('notification.add', pathPattern, channel);
-      const responseParams = {
-        createdUser: await UpdatePost.createUpdatePost(pathPattern, channel, req.user),
         userNotifications: await UpdatePost.findAll(),
         userNotifications: await UpdatePost.findAll(),
+        isNotificationForOwnerPageEnabled: await crowi.configManager.getConfig(
+          'notification:owner-page:isEnabled',
+        ),
+        isNotificationForGroupPageEnabled: await crowi.configManager.getConfig(
+          'notification:group-page:isEnabled',
+        ),
+        globalNotifications: await GlobalNotificationSetting.findAll(),
       };
       };
+      return res.apiv3({ notificationParams });
+    },
+  );
+
+  /**
+   * @swagger
+   *
+   *    /notification-setting/user-notification:
+   *      post:
+   *        tags: [NotificationSetting]
+   *        security:
+   *         - cookieAuth: []
+   *        description: add user notification setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/UserNotificationParams'
+   *        responses:
+   *          200:
+   *            description: Succeeded to add user notification setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    responseParams:
+   *                      type: object
+   *                      description: response params
+   *                      properties:
+   *                        createdUser:
+   *                          $ref: '#/components/schemas/User'
+   *                          description: user who set notification
+   *                        userNotifications:
+   *                          type: array
+   *                          items:
+   *                            $ref: '#/components/schemas/UserNotification'
+   *                            description: user notification settings
+   */
+  // eslint-disable-next-line max-len
+  router.post(
+    '/user-notification',
+    accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
+    Strictly,
+    adminRequired,
+    addActivity,
+    validator.userNotification,
+    apiV3FormValidator,
+    async (req, res) => {
+      const { pathPattern, channel } = req.body;
 
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_ADD };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+      try {
+        logger.info('notification.add', pathPattern, channel);
+        const responseParams = {
+          createdUser: await UpdatePost.createUpdatePost(
+            pathPattern,
+            channel,
+            req.user,
+          ),
+          userNotifications: await UpdatePost.findAll(),
+        };
 
 
-      return res.apiv3({ responseParams }, 201);
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating user notification';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-userNotification-failed'));
-    }
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_ADD,
+        };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
-  });
+        return res.apiv3({ responseParams }, 201);
+      } catch (err) {
+        const msg = 'Error occurred in updating user notification';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-userNotification-failed'));
+      }
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -307,29 +342,36 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/UserNotification'
    *                  $ref: '#/components/schemas/UserNotification'
    */
    */
-  router.delete('/user-notification/:id',
+  router.delete(
+    '/user-notification/:id',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     Strictly,
     Strictly,
     adminRequired,
     adminRequired,
     addActivity,
     addActivity,
-    async(req, res) => {
+    async (req, res) => {
       const { id } = req.params;
       const { id } = req.params;
 
 
       try {
       try {
-        const deletedNotificaton = await UpdatePost.findOneAndRemove({ _id: id });
+        const deletedNotificaton = await UpdatePost.findOneAndRemove({
+          _id: id,
+        });
 
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE };
+        const parameters = {
+          action:
+            SupportedAction.ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3(deletedNotificaton);
         return res.apiv3(deletedNotificaton);
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in delete user trigger notification';
         const msg = 'Error occurred in delete user trigger notification';
         logger.error('Error', err);
         logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'delete-userTriggerNotification-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'delete-userTriggerNotification-failed'),
+        );
       }
       }
-    });
-
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -357,27 +399,31 @@ module.exports = (crowi) => {
    *                    globalNotification:
    *                    globalNotification:
    *                      $ref: '#/components/schemas/GlobalNotification'
    *                      $ref: '#/components/schemas/GlobalNotification'
    */
    */
-  router.get('/global-notification/:id',
+  router.get(
+    '/global-notification/:id',
     accessTokenParser([SCOPE.READ.ADMIN.EXTERNAL_NOTIFICATION]),
     accessTokenParser([SCOPE.READ.ADMIN.EXTERNAL_NOTIFICATION]),
     Strictly,
     Strictly,
     adminRequired,
     adminRequired,
     validator.globalNotification,
     validator.globalNotification,
-    async(req, res) => {
-
+    async (req, res) => {
       const notificationSettingId = req.params.id;
       const notificationSettingId = req.params.id;
       let globalNotification;
       let globalNotification;
 
 
       if (notificationSettingId) {
       if (notificationSettingId) {
         try {
         try {
-          globalNotification = await GlobalNotificationSetting.findOne({ _id: notificationSettingId });
-        }
-        catch (err) {
-          logger.error(`Error in finding a global notification setting with {_id: ${notificationSettingId}}`);
+          globalNotification = await GlobalNotificationSetting.findOne({
+            _id: notificationSettingId,
+          });
+        } catch (err) {
+          logger.error(
+            `Error in finding a global notification setting with {_id: ${notificationSettingId}}`,
+          );
         }
         }
       }
       }
 
 
       return res.apiv3({ globalNotification });
       return res.apiv3({ globalNotification });
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -407,17 +453,17 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/GlobalNotification'
    *                      $ref: '#/components/schemas/GlobalNotification'
    */
    */
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  router.post('/global-notification',
+  router.post(
+    '/global-notification',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     Strictly,
     Strictly,
     adminRequired,
     adminRequired,
     addActivity,
     addActivity,
     validator.globalNotification,
     validator.globalNotification,
     apiV3FormValidator,
     apiV3FormValidator,
-    async(req, res) => {
-      const {
-        notifyType, toEmail, slackChannels, triggerPath, triggerEvents,
-      } = req.body;
+    async (req, res) => {
+      const { notifyType, toEmail, slackChannels, triggerPath, triggerEvents } =
+        req.body;
 
 
       let notification;
       let notification;
 
 
@@ -436,17 +482,19 @@ module.exports = (crowi) => {
       try {
       try {
         const createdNotification = await notification.save();
         const createdNotification = await notification.save();
 
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ createdNotification }, 201);
         return res.apiv3({ createdNotification }, 201);
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating global notification';
         const msg = 'Error occurred in updating global notification';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'post-globalNotification-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'post-globalNotification-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -481,18 +529,18 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/GlobalNotification'
    *                      $ref: '#/components/schemas/GlobalNotification'
    */
    */
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  router.put('/global-notification/:id',
+  router.put(
+    '/global-notification/:id',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     Strictly,
     Strictly,
     adminRequired,
     adminRequired,
     addActivity,
     addActivity,
     validator.globalNotification,
     validator.globalNotification,
     apiV3FormValidator,
     apiV3FormValidator,
-    async(req, res) => {
+    async (req, res) => {
       const { id } = req.params;
       const { id } = req.params;
-      const {
-        notifyType, toEmail, slackChannels, triggerPath, triggerEvents,
-      } = req.body;
+      const { notifyType, toEmail, slackChannels, triggerPath, triggerEvents } =
+        req.body;
 
 
       const models = {
       const models = {
         [GlobalNotificationSettingType.MAIL]: GlobalNotificationMailSetting,
         [GlobalNotificationSettingType.MAIL]: GlobalNotificationMailSetting,
@@ -528,19 +576,20 @@ module.exports = (crowi) => {
 
 
         const createdNotification = await setting.save();
         const createdNotification = await setting.save();
 
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE };
+        const parameters = {
+          action:
+            SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ createdNotification });
         return res.apiv3({ createdNotification });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating global notification';
         const msg = 'Error occurred in updating global notification';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'post-globalNotification-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'post-globalNotification-failed'));
       }
       }
-
-    });
-
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -566,18 +615,20 @@ module.exports = (crowi) => {
    *                  $ref: '#/components/schemas/NotifyForPageGrant'
    *                  $ref: '#/components/schemas/NotifyForPageGrant'
    */
    */
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  router.put('/notify-for-page-grant',
+  router.put(
+    '/notify-for-page-grant',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     Strictly,
     Strictly,
     adminRequired,
     adminRequired,
     addActivity,
     addActivity,
     validator.notifyForPageGrant,
     validator.notifyForPageGrant,
     apiV3FormValidator,
     apiV3FormValidator,
-    async(req, res) => {
-
+    async (req, res) => {
       let requestParams = {
       let requestParams = {
-        'notification:owner-page:isEnabled': req.body.isNotificationForOwnerPageEnabled,
-        'notification:group-page:isEnabled': req.body.isNotificationForGroupPageEnabled,
+        'notification:owner-page:isEnabled':
+          req.body.isNotificationForOwnerPageEnabled,
+        'notification:group-page:isEnabled':
+          req.body.isNotificationForGroupPageEnabled,
       };
       };
 
 
       requestParams = removeNullPropertyFromObject(requestParams);
       requestParams = removeNullPropertyFromObject(requestParams);
@@ -585,22 +636,32 @@ module.exports = (crowi) => {
       try {
       try {
         await configManager.updateConfigs(requestParams);
         await configManager.updateConfigs(requestParams);
         const responseParams = {
         const responseParams = {
-          isNotificationForOwnerPageEnabled: await crowi.configManager.getConfig('notification:owner-page:isEnabled'),
-          isNotificationForGroupPageEnabled: await crowi.configManager.getConfig('notification:group-page:isEnabled'),
+          isNotificationForOwnerPageEnabled:
+            await crowi.configManager.getConfig(
+              'notification:owner-page:isEnabled',
+            ),
+          isNotificationForGroupPageEnabled:
+            await crowi.configManager.getConfig(
+              'notification:group-page:isEnabled',
+            ),
         };
         };
 
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE };
+        const parameters = {
+          action:
+            SupportedAction.ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ responseParams });
         return res.apiv3({ responseParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating notify for page grant';
         const msg = 'Error occurred in updating notify for page grant';
         logger.error('Error', err);
         logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'update-notify-for-page-grant-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'update-notify-for-page-grant-failed'),
+        );
       }
       }
-
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -638,20 +699,20 @@ module.exports = (crowi) => {
    *                      type: string
    *                      type: string
    *                      description: notification id
    *                      description: notification id
    */
    */
-  router.put('/global-notification/:id/enabled',
+  router.put(
+    '/global-notification/:id/enabled',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     Strictly,
     Strictly,
     adminRequired,
     adminRequired,
     addActivity,
     addActivity,
-    async(req, res) => {
+    async (req, res) => {
       const { id } = req.params;
       const { id } = req.params;
       const { isEnabled } = req.body;
       const { isEnabled } = req.body;
 
 
       try {
       try {
         if (isEnabled) {
         if (isEnabled) {
           await GlobalNotificationSetting.enable(id);
           await GlobalNotificationSetting.enable(id);
-        }
-        else {
+        } else {
           await GlobalNotificationSetting.disable(id);
           await GlobalNotificationSetting.disable(id);
         }
         }
 
 
@@ -663,64 +724,70 @@ module.exports = (crowi) => {
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ id });
         return res.apiv3({ id });
-
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in toggle of global notification';
         const msg = 'Error occurred in toggle of global notification';
         logger.error('Error', err);
         logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'toggle-globalNotification-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'toggle-globalNotification-failed'),
+        );
       }
       }
-
-    });
+    },
+  );
 
 
   /**
   /**
-  * @swagger
-  *
-  *    /notification-setting/global-notification/{id}:
-  *      delete:
-  *        tags: [NotificationSetting]
-  *        security:
-  *          - cookieAuth: []
-  *        description: delete global notification pattern
-  *        parameters:
-  *          - name: id
-  *            in: path
-  *            required: true
-  *            description: id of global notification
-  *            schema:
-  *              type: string
-  *        responses:
-  *          200:
-  *            description: Succeeded to delete global notification pattern
-  *            content:
-  *              application/json:
-  *                schema:
-  *                  description: deleted notification
-  *                  $ref: '#/components/schemas/GlobalNotification'
-  */
-  router.delete('/global-notification/:id',
+   * @swagger
+   *
+   *    /notification-setting/global-notification/{id}:
+   *      delete:
+   *        tags: [NotificationSetting]
+   *        security:
+   *          - cookieAuth: []
+   *        description: delete global notification pattern
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of global notification
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Succeeded to delete global notification pattern
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  description: deleted notification
+   *                  $ref: '#/components/schemas/GlobalNotification'
+   */
+  router.delete(
+    '/global-notification/:id',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
     Strictly,
     Strictly,
     adminRequired,
     adminRequired,
     addActivity,
     addActivity,
-    async(req, res) => {
+    async (req, res) => {
       const { id } = req.params;
       const { id } = req.params;
 
 
       try {
       try {
-        const deletedNotificaton = await GlobalNotificationSetting.findOneAndRemove({ _id: id });
+        const deletedNotificaton =
+          await GlobalNotificationSetting.findOneAndRemove({ _id: id });
 
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE };
+        const parameters = {
+          action:
+            SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3(deletedNotificaton);
         return res.apiv3(deletedNotificaton);
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in delete global notification';
         const msg = 'Error occurred in delete global notification';
         logger.error('Error', err);
         logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'delete-globalNotification-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'delete-globalNotification-failed'),
+        );
       }
       }
-
-    });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 4 - 3
apps/app/src/server/routes/apiv3/response.js

@@ -3,8 +3,8 @@ import { ErrorV3 } from '@growi/core/dist/models';
 import { toArrayIfNot } from '~/utils/array-utils';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 
 const addCustomFunctionToResponse = (express) => {
 const addCustomFunctionToResponse = (express) => {
-
-  express.response.apiv3 = function(obj = {}, status = 200) { // not arrow function
+  express.response.apiv3 = function (obj = {}, status = 200) {
+    // not arrow function
     // obj must be object
     // obj must be object
     if (typeof obj !== 'object' || obj instanceof Array) {
     if (typeof obj !== 'object' || obj instanceof Array) {
       throw new Error('invalid value supplied to res.apiv3');
       throw new Error('invalid value supplied to res.apiv3');
@@ -13,7 +13,8 @@ const addCustomFunctionToResponse = (express) => {
     this.status(status).json(obj);
     this.status(status).json(obj);
   };
   };
 
 
-  express.response.apiv3Err = function(_err, status = 400, info) { // not arrow function
+  express.response.apiv3Err = function (_err, status = 400, info) {
+    // not arrow function
     if (!Number.isInteger(status)) {
     if (!Number.isInteger(status)) {
       throw new Error('invalid status supplied to res.apiv3Err');
       throw new Error('invalid status supplied to res.apiv3Err');
     }
     }

+ 74 - 35
apps/app/src/server/routes/apiv3/revisions.js

@@ -1,9 +1,9 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import express from 'express';
 import express from 'express';
 import { connection } from 'mongoose';
 import { connection } from 'mongoose';
 
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { Revision } from '~/server/models/revision';
 import { Revision } from '~/server/models/revision';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
@@ -17,7 +17,8 @@ const { query, param } = require('express-validator');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
-const MIGRATION_FILE_NAME = '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549';
+const MIGRATION_FILE_NAME =
+  '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549';
 
 
 /**
 /**
  * @swagger
  * @swagger
@@ -56,20 +57,27 @@ const MIGRATION_FILE_NAME = '20211227060705-revision-path-to-page-id-schema-migr
  */
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const certifySharedPage = require('../../middlewares/certify-shared-page')(
+    crowi,
+  );
+  const loginRequired = require('../../middlewares/login-required')(
+    crowi,
+    true,
+  );
 
 
-  const {
-    Page,
-    User,
-  } = crowi.models;
+  const { Page, User } = crowi.models;
 
 
   const validator = {
   const validator = {
     retrieveRevisions: [
     retrieveRevisions: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageId').isMongoId().withMessage('pageId is required'),
-      query('offset').if(value => value != null).isInt({ min: 0 }).withMessage('offset must be int'),
-      query('limit').if(value => value != null).isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
-
+      query('offset')
+        .if((value) => value != null)
+        .isInt({ min: 0 })
+        .withMessage('offset must be int'),
+      query('limit')
+        .if((value) => value != null)
+        .isInt({ max: 100 })
+        .withMessage('You should set less than 100 or not to set limit.'),
     ],
     ],
     retrieveRevisionById: [
     retrieveRevisionById: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageId').isMongoId().withMessage('pageId is required'),
@@ -79,14 +87,15 @@ module.exports = (crowi) => {
 
 
   let cachedAppliedAt = null;
   let cachedAppliedAt = null;
 
 
-  const getAppliedAtOfTheMigrationFile = async() => {
-
+  const getAppliedAtOfTheMigrationFile = async () => {
     if (cachedAppliedAt != null) {
     if (cachedAppliedAt != null) {
       return cachedAppliedAt;
       return cachedAppliedAt;
     }
     }
 
 
     const migrationCollection = connection.collection('migrations');
     const migrationCollection = connection.collection('migrations');
-    const migration = await migrationCollection.findOne({ fileName: { $regex: `^${MIGRATION_FILE_NAME}` } });
+    const migration = await migrationCollection.findOne({
+      fileName: { $regex: `^${MIGRATION_FILE_NAME}` },
+    });
     const appliedAt = migration.appliedAt;
     const appliedAt = migration.appliedAt;
 
 
     cachedAppliedAt = appliedAt;
     cachedAppliedAt = appliedAt;
@@ -135,24 +144,42 @@ module.exports = (crowi) => {
    *                    type: number
    *                    type: number
    *                    description: offset of the revisions
    *                    description: offset of the revisions
    */
    */
-  router.get('/list',
-    certifySharedPage, accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequired, validator.retrieveRevisions, apiV3FormValidator,
-    async(req, res) => {
+  router.get(
+    '/list',
+    certifySharedPage,
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired,
+    validator.retrieveRevisions,
+    apiV3FormValidator,
+    async (req, res) => {
       const pageId = req.query.pageId;
       const pageId = req.query.pageId;
-      const limit = req.query.limit || await crowi.configManager.getConfig('customize:showPageLimitationS') || 10;
+      const limit =
+        req.query.limit ||
+        (await crowi.configManager.getConfig(
+          'customize:showPageLimitationS',
+        )) ||
+        10;
       const { isSharedPage } = req;
       const { isSharedPage } = req;
       const offset = req.query.offset || 0;
       const offset = req.query.offset || 0;
 
 
       // check whether accessible
       // check whether accessible
-      if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
-        return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
+      if (
+        !isSharedPage &&
+        !(await Page.isAccessiblePageByViewer(pageId, req.user))
+      ) {
+        return res.apiv3Err(
+          new ErrorV3(
+            'Current user is not accessible to this page.',
+            'forbidden-page',
+          ),
+          403,
+        );
       }
       }
 
 
       // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
       // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
       try {
       try {
         await normalizeLatestRevisionIfBroken(pageId);
         await normalizeLatestRevisionIfBroken(pageId);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Error occurred in normalizing the latest revision');
         logger.error('Error occurred in normalizing the latest revision');
       }
       }
 
 
@@ -197,14 +224,13 @@ module.exports = (crowi) => {
         };
         };
 
 
         return res.apiv3(result);
         return res.apiv3(result);
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in getting revisions by poge id';
         const msg = 'Error occurred in getting revisions by poge id';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'faild-to-find-revisions'), 500);
         return res.apiv3Err(new ErrorV3(msg, 'faild-to-find-revisions'), 500);
       }
       }
-
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -236,16 +262,30 @@ module.exports = (crowi) => {
    *                    revision:
    *                    revision:
    *                      $ref: '#/components/schemas/Revision'
    *                      $ref: '#/components/schemas/Revision'
    */
    */
-  router.get('/:id',
-    certifySharedPage, accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequired, validator.retrieveRevisionById, apiV3FormValidator,
-    async(req, res) => {
+  router.get(
+    '/:id',
+    certifySharedPage,
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired,
+    validator.retrieveRevisionById,
+    apiV3FormValidator,
+    async (req, res) => {
       const revisionId = req.params.id;
       const revisionId = req.params.id;
       const pageId = req.query.pageId;
       const pageId = req.query.pageId;
       const { isSharedPage } = req;
       const { isSharedPage } = req;
 
 
       // check whether accessible
       // check whether accessible
-      if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
-        return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
+      if (
+        !isSharedPage &&
+        !(await Page.isAccessiblePageByViewer(pageId, req.user))
+      ) {
+        return res.apiv3Err(
+          new ErrorV3(
+            'Current user is not accessible to this page.',
+            'forbidden-page',
+          ),
+          403,
+        );
       }
       }
 
 
       try {
       try {
@@ -256,14 +296,13 @@ module.exports = (crowi) => {
         }
         }
 
 
         return res.apiv3({ revision });
         return res.apiv3({ revision });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in getting revision data by id';
         const msg = 'Error occurred in getting revision data by id';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'faild-to-find-revision'), 500);
         return res.apiv3Err(new ErrorV3(msg, 'faild-to-find-revision'), 500);
       }
       }
-
-    });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 80 - 27
apps/app/src/server/routes/apiv3/search.js

@@ -126,23 +126,36 @@ module.exports = (crowi) => {
    *                    description: Status of indices
    *                    description: Status of indices
    *                    $ref: '#/components/schemas/Indices'
    *                    $ref: '#/components/schemas/Indices'
    */
    */
-  router.get('/indices',
-    noCache(), accessTokenParser([SCOPE.READ.ADMIN.FULL_TEXT_SEARCH], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
+  router.get(
+    '/indices',
+    noCache(),
+    accessTokenParser([SCOPE.READ.ADMIN.FULL_TEXT_SEARCH], {
+      acceptLegacy: true,
+    }),
+    loginRequired,
+    adminRequired,
+    async (req, res) => {
       const { searchService } = crowi;
       const { searchService } = crowi;
 
 
       if (!searchService.isConfigured) {
       if (!searchService.isConfigured) {
-        return res.apiv3Err(new ErrorV3('SearchService is not configured', 'search-service-unconfigured'), 503);
+        return res.apiv3Err(
+          new ErrorV3(
+            'SearchService is not configured',
+            'search-service-unconfigured',
+          ),
+          503,
+        );
       }
       }
 
 
       try {
       try {
         const info = await searchService.getInfoForAdmin();
         const info = await searchService.getInfoForAdmin();
         return res.status(200).send({ info });
         return res.status(200).send({ info });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err(err, 503);
         return res.apiv3Err(err, 503);
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -156,26 +169,40 @@ module.exports = (crowi) => {
    *        200:
    *        200:
    *          description: Successfully connected
    *          description: Successfully connected
    */
    */
-  router.post('/connection',
-    accessTokenParser([SCOPE.WRITE.ADMIN.FULL_TEXT_SEARCH], { acceptLegacy: true }), loginRequired, adminRequired, addActivity, async(req, res) => {
+  router.post(
+    '/connection',
+    accessTokenParser([SCOPE.WRITE.ADMIN.FULL_TEXT_SEARCH], {
+      acceptLegacy: true,
+    }),
+    loginRequired,
+    adminRequired,
+    addActivity,
+    async (req, res) => {
       const { searchService } = crowi;
       const { searchService } = crowi;
 
 
       if (!searchService.isConfigured) {
       if (!searchService.isConfigured) {
-        return res.apiv3Err(new ErrorV3('SearchService is not configured', 'search-service-unconfigured'));
+        return res.apiv3Err(
+          new ErrorV3(
+            'SearchService is not configured',
+            'search-service-unconfigured',
+          ),
+        );
       }
       }
 
 
       try {
       try {
         await searchService.reconnectClient();
         await searchService.reconnectClient();
 
 
-        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SEARCH_CONNECTION });
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_ADMIN_SEARCH_CONNECTION,
+        });
 
 
         return res.status(200).send();
         return res.status(200).send();
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err(err, 503);
         return res.apiv3Err(err, 503);
       }
       }
-    });
+    },
+  );
 
 
   const validatorForPutIndices = [
   const validatorForPutIndices = [
     body('operation').isString().isIn(['rebuild', 'normalize']),
     body('operation').isString().isIn(['rebuild', 'normalize']),
@@ -212,44 +239,70 @@ module.exports = (crowi) => {
    *                    type: string
    *                    type: string
    *                    description: Operation is successfully processed, or requested
    *                    description: Operation is successfully processed, or requested
    */
    */
-  router.put('/indices', accessTokenParser([SCOPE.WRITE.ADMIN.FULL_TEXT_SEARCH], { acceptLegacy: true }), loginRequired, adminRequired, addActivity,
-    validatorForPutIndices, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/indices',
+    accessTokenParser([SCOPE.WRITE.ADMIN.FULL_TEXT_SEARCH], {
+      acceptLegacy: true,
+    }),
+    loginRequired,
+    adminRequired,
+    addActivity,
+    validatorForPutIndices,
+    apiV3FormValidator,
+    async (req, res) => {
       const operation = req.body.operation;
       const operation = req.body.operation;
 
 
       const { searchService } = crowi;
       const { searchService } = crowi;
 
 
       if (!searchService.isConfigured) {
       if (!searchService.isConfigured) {
-        return res.apiv3Err(new ErrorV3('SearchService is not configured', 'search-service-unconfigured'));
+        return res.apiv3Err(
+          new ErrorV3(
+            'SearchService is not configured',
+            'search-service-unconfigured',
+          ),
+        );
       }
       }
       if (!searchService.isReachable) {
       if (!searchService.isReachable) {
-        return res.apiv3Err(new ErrorV3('SearchService is not reachable', 'search-service-unreachable'));
+        return res.apiv3Err(
+          new ErrorV3(
+            'SearchService is not reachable',
+            'search-service-unreachable',
+          ),
+        );
       }
       }
 
 
       try {
       try {
         switch (operation) {
         switch (operation) {
           case 'normalize':
           case 'normalize':
-          // wait the processing is terminated
+            // wait the processing is terminated
             await searchService.normalizeIndices();
             await searchService.normalizeIndices();
 
 
-            activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SEARCH_INDICES_NORMALIZE });
+            activityEvent.emit('update', res.locals.activity._id, {
+              action: SupportedAction.ACTION_ADMIN_SEARCH_INDICES_NORMALIZE,
+            });
 
 
-            return res.status(200).send({ message: 'Operation is successfully processed.' });
+            return res
+              .status(200)
+              .send({ message: 'Operation is successfully processed.' });
           case 'rebuild':
           case 'rebuild':
-          // NOT wait the processing is terminated
+            // NOT wait the processing is terminated
             searchService.rebuildIndex();
             searchService.rebuildIndex();
 
 
-            activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SEARCH_INDICES_REBUILD });
+            activityEvent.emit('update', res.locals.activity._id, {
+              action: SupportedAction.ACTION_ADMIN_SEARCH_INDICES_REBUILD,
+            });
 
 
-            return res.status(200).send({ message: 'Operation is successfully requested.' });
+            return res
+              .status(200)
+              .send({ message: 'Operation is successfully requested.' });
           default:
           default:
             throw new Error(`Unimplemented operation: ${operation}`);
             throw new Error(`Unimplemented operation: ${operation}`);
         }
         }
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(err, 503);
         return res.apiv3Err(err, 503);
       }
       }
-    });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 167 - 119
apps/app/src/server/routes/apiv3/share-links.js

@@ -95,18 +95,24 @@ module.exports = (crowi) => {
    * middleware to limit link sharing
    * middleware to limit link sharing
    */
    */
   const linkSharingRequired = (req, res, next) => {
   const linkSharingRequired = (req, res, next) => {
-    const isLinkSharingDisabled = crowi.configManager.getConfig('security:disableLinkSharing');
+    const isLinkSharingDisabled = crowi.configManager.getConfig(
+      'security:disableLinkSharing',
+    );
     logger.debug(`isLinkSharingDisabled: ${isLinkSharingDisabled}`);
     logger.debug(`isLinkSharingDisabled: ${isLinkSharingDisabled}`);
 
 
     if (isLinkSharingDisabled) {
     if (isLinkSharingDisabled) {
-      return res.apiv3Err(new ErrorV3('Link sharing is disabled', 'link-sharing-disabled'));
+      return res.apiv3Err(
+        new ErrorV3('Link sharing is disabled', 'link-sharing-disabled'),
+      );
     }
     }
     next();
     next();
   };
   };
 
 
   validator.getShareLinks = [
   validator.getShareLinks = [
     // validate the page id is MongoId
     // validate the page id is MongoId
-    query('relatedPage').isMongoId().withMessage('Page Id is required'),
+    query('relatedPage')
+      .isMongoId()
+      .withMessage('Page Id is required'),
   ];
   ];
 
 
   /**
   /**
@@ -138,13 +144,14 @@ module.exports = (crowi) => {
    *                      items:
    *                      items:
    *                        $ref: '#/components/schemas/ShareLink'
    *                        $ref: '#/components/schemas/ShareLink'
    */
    */
-  router.get('/',
+  router.get(
+    '/',
     accessTokenParser([SCOPE.READ.FEATURES.SHARE_LINK]),
     accessTokenParser([SCOPE.READ.FEATURES.SHARE_LINK]),
     loginRequired,
     loginRequired,
     linkSharingRequired,
     linkSharingRequired,
     validator.getShareLinks,
     validator.getShareLinks,
     apiV3FormValidator,
     apiV3FormValidator,
-    async(req, res) => {
+    async (req, res) => {
       const { relatedPage } = req.query;
       const { relatedPage } = req.query;
 
 
       const page = await Page.findByIdAndViewer(relatedPage, req.user);
       const page = await Page.findByIdAndViewer(relatedPage, req.user);
@@ -156,23 +163,32 @@ module.exports = (crowi) => {
       }
       }
 
 
       try {
       try {
-        const shareLinksResult = await ShareLink.find({ relatedPage: { $eq: relatedPage } }).populate({ path: 'relatedPage', select: 'path' });
+        const shareLinksResult = await ShareLink.find({
+          relatedPage: { $eq: relatedPage },
+        }).populate({ path: 'relatedPage', select: 'path' });
         return res.apiv3({ shareLinksResult });
         return res.apiv3({ shareLinksResult });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in get share link';
         const msg = 'Error occurred in get share link';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'get-shareLink-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'get-shareLink-failed'));
       }
       }
-    });
+    },
+  );
 
 
   validator.shareLinkStatus = [
   validator.shareLinkStatus = [
     // validate the page id is MongoId
     // validate the page id is MongoId
-    body('relatedPage').isMongoId().withMessage('Page Id is required'),
+    body('relatedPage')
+      .isMongoId()
+      .withMessage('Page Id is required'),
     // validate expireation date is not empty, is not before today and is date.
     // validate expireation date is not empty, is not before today and is date.
-    body('expiredAt').if(value => value != null).isAfter(today.toString()).withMessage('Your Selected date is past'),
+    body('expiredAt')
+      .if((value) => value != null)
+      .isAfter(today.toString())
+      .withMessage('Your Selected date is past'),
     // validate the length of description is max 100.
     // validate the length of description is max 100.
-    body('description').isLength({ min: 0, max: 100 }).withMessage('Max length is 100'),
+    body('description')
+      .isLength({ min: 0, max: 100 })
+      .withMessage('Max length is 100'),
   ];
   ];
 
 
   /**
   /**
@@ -209,7 +225,8 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                 $ref: '#/components/schemas/ShareLinkSimple'
    *                 $ref: '#/components/schemas/ShareLinkSimple'
    */
    */
-  router.post('/',
+  router.post(
+    '/',
     accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]),
     accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]),
     loginRequired,
     loginRequired,
     excludeReadOnlyUser,
     excludeReadOnlyUser,
@@ -217,7 +234,7 @@ module.exports = (crowi) => {
     addActivity,
     addActivity,
     validator.shareLinkStatus,
     validator.shareLinkStatus,
     apiV3FormValidator,
     apiV3FormValidator,
-    async(req, res) => {
+    async (req, res) => {
       const { relatedPage, expiredAt, description } = req.body;
       const { relatedPage, expiredAt, description } = req.body;
 
 
       const page = await Page.findByIdAndViewer(relatedPage, req.user);
       const page = await Page.findByIdAndViewer(relatedPage, req.user);
@@ -229,146 +246,173 @@ module.exports = (crowi) => {
       }
       }
 
 
       try {
       try {
-        const postedShareLink = await ShareLink.create({ relatedPage, expiredAt, description });
+        const postedShareLink = await ShareLink.create({
+          relatedPage,
+          expiredAt,
+          description,
+        });
 
 
-        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_CREATE });
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_SHARE_LINK_CREATE,
+        });
 
 
         return res.apiv3(postedShareLink, 201);
         return res.apiv3(postedShareLink, 201);
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occured in post share link';
         const msg = 'Error occured in post share link';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'post-shareLink-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'post-shareLink-failed'));
       }
       }
-    });
-
+    },
+  );
 
 
   validator.deleteShareLinks = [
   validator.deleteShareLinks = [
     // validate the page id is MongoId
     // validate the page id is MongoId
-    query('relatedPage').isMongoId().withMessage('Page Id is required'),
+    query('relatedPage')
+      .isMongoId()
+      .withMessage('Page Id is required'),
   ];
   ];
 
 
   /**
   /**
-  * @swagger
-  *
-  *    /share-links/:
-  *      delete:
-  *        tags: [ShareLinks]
-  *        security:
-  *          - cookieAuth: []
-  *        summary: delete all share links related one page
-  *        description: delete all share links related one page
-  *        parameters:
-  *          - name: relatedPage
-  *            in: query
-  *            required: true
-  *            description: page id of share link
-  *            schema:
-  *              type: string
-  *        responses:
-  *          200:
-  *            description: Succeeded to delete o all share links related one page
-  *            content:
-  *              application/json:
-  *                schema:
-  *                 $ref: '#/components/schemas/ShareLinkSimple'
-  */
-  router.delete('/',
+   * @swagger
+   *
+   *    /share-links/:
+   *      delete:
+   *        tags: [ShareLinks]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: delete all share links related one page
+   *        description: delete all share links related one page
+   *        parameters:
+   *          - name: relatedPage
+   *            in: query
+   *            required: true
+   *            description: page id of share link
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Succeeded to delete o all share links related one page
+   *            content:
+   *              application/json:
+   *                schema:
+   *                 $ref: '#/components/schemas/ShareLinkSimple'
+   */
+  router.delete(
+    '/',
     accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]),
     accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]),
     loginRequired,
     loginRequired,
     excludeReadOnlyUser,
     excludeReadOnlyUser,
     addActivity,
     addActivity,
     validator.deleteShareLinks,
     validator.deleteShareLinks,
     apiV3FormValidator,
     apiV3FormValidator,
-    async(req, res) => {
+    async (req, res) => {
       const { relatedPage } = req.query;
       const { relatedPage } = req.query;
       const page = await Page.findByIdAndViewer(relatedPage, req.user);
       const page = await Page.findByIdAndViewer(relatedPage, req.user);
 
 
       if (page == null) {
       if (page == null) {
         const msg = 'Page is not found or forbidden';
         const msg = 'Page is not found or forbidden';
         logger.error('Error', msg);
         logger.error('Error', msg);
-        return res.apiv3Err(new ErrorV3(msg, 'delete-shareLinks-for-page-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'delete-shareLinks-for-page-failed'),
+        );
       }
       }
 
 
       try {
       try {
-        const deletedShareLink = await ShareLink.deleteMany({ relatedPage: { $eq: relatedPage } });
+        const deletedShareLink = await ShareLink.deleteMany({
+          relatedPage: { $eq: relatedPage },
+        });
 
 
-        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_DELETE_BY_PAGE });
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_SHARE_LINK_DELETE_BY_PAGE,
+        });
 
 
         return res.apiv3(deletedShareLink);
         return res.apiv3(deletedShareLink);
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occured in delete share link';
         const msg = 'Error occured in delete share link';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
-  * @swagger
-  *
-  *    /share-links/all:
-  *      delete:
-  *        tags: [ShareLink Management]
-  *        security:
-  *         - cookieAuth: []
-  *        summary: delete all share links
-  *        description: delete all share links
-  *        responses:
-  *          200:
-  *            description: Succeeded to remove all share links
-  *            content:
-  *              application/json:
-  *                schema:
-  *                  properties:
-  *                    deletedCount:
-  *                      type: integer
-  *                      description: The number of share links deleted
-  */
-  router.delete('/all', accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]), loginRequired, adminRequired, addActivity, async(req, res) => {
-
-    try {
-      const deletedShareLink = await ShareLink.deleteMany({});
-      const { deletedCount } = deletedShareLink;
-
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_ALL_DELETE });
-
-      return res.apiv3({ deletedCount });
-    }
-    catch (err) {
-      const msg = 'Error occurred in delete all share link';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'delete-all-shareLink-failed'));
-    }
-  });
+   * @swagger
+   *
+   *    /share-links/all:
+   *      delete:
+   *        tags: [ShareLink Management]
+   *        security:
+   *         - cookieAuth: []
+   *        summary: delete all share links
+   *        description: delete all share links
+   *        responses:
+   *          200:
+   *            description: Succeeded to remove all share links
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    deletedCount:
+   *                      type: integer
+   *                      description: The number of share links deleted
+   */
+  router.delete(
+    '/all',
+    accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]),
+    loginRequired,
+    adminRequired,
+    addActivity,
+    async (req, res) => {
+      try {
+        const deletedShareLink = await ShareLink.deleteMany({});
+        const { deletedCount } = deletedShareLink;
+
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_SHARE_LINK_ALL_DELETE,
+        });
+
+        return res.apiv3({ deletedCount });
+      } catch (err) {
+        const msg = 'Error occurred in delete all share link';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'delete-all-shareLink-failed'));
+      }
+    },
+  );
 
 
   validator.deleteShareLink = [
   validator.deleteShareLink = [
     param('id').isMongoId().withMessage('ShareLink Id is required'),
     param('id').isMongoId().withMessage('ShareLink Id is required'),
   ];
   ];
 
 
   /**
   /**
-  * @swagger
-  *
-  *    /share-links/{id}:
-  *      delete:
-  *        tags: [ShareLinks]
-  *        security:
-  *          - cookieAuth: []
-  *        description: delete one share link related one page
-  *        parameters:
-  *          - name: id
-  *            in: path
-  *            required: true
-  *            description: id of share link
-  *            schema:
-  *              type: string
-  *        responses:
-  *          200:
-  *            description: Succeeded to delete one share link
-  */
-  router.delete('/:id', accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]), loginRequired, excludeReadOnlyUser, addActivity,
-    validator.deleteShareLink, apiV3FormValidator,
-    async(req, res) => {
+   * @swagger
+   *
+   *    /share-links/{id}:
+   *      delete:
+   *        tags: [ShareLinks]
+   *        security:
+   *          - cookieAuth: []
+   *        description: delete one share link related one page
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of share link
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Succeeded to delete one share link
+   */
+  router.delete(
+    '/:id',
+    accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]),
+    loginRequired,
+    excludeReadOnlyUser,
+    addActivity,
+    validator.deleteShareLink,
+    apiV3FormValidator,
+    async (req, res) => {
       const { id } = req.params;
       const { id } = req.params;
       const { user } = req;
       const { user } = req;
 
 
@@ -377,8 +421,12 @@ module.exports = (crowi) => {
 
 
         // check permission
         // check permission
         if (!user.isAdmin) {
         if (!user.isAdmin) {
-          const page = await Page.findByIdAndViewer(shareLinkToDelete.relatedPage, user);
-          const isPageExists = (await Page.count({ _id: shareLinkToDelete.relatedPage }) > 0);
+          const page = await Page.findByIdAndViewer(
+            shareLinkToDelete.relatedPage,
+            user,
+          );
+          const isPageExists =
+            (await Page.count({ _id: shareLinkToDelete.relatedPage })) > 0;
           if (page == null && isPageExists) {
           if (page == null && isPageExists) {
             const msg = 'Page is not found or forbidden';
             const msg = 'Page is not found or forbidden';
             logger.error('Error', msg);
             logger.error('Error', msg);
@@ -389,18 +437,18 @@ module.exports = (crowi) => {
         // remove
         // remove
         await shareLinkToDelete.remove();
         await shareLinkToDelete.remove();
 
 
-        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_DELETE });
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_SHARE_LINK_DELETE,
+        });
 
 
         return res.apiv3({ deletedShareLink: shareLinkToDelete });
         return res.apiv3({ deletedShareLink: shareLinkToDelete });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in delete share link';
         const msg = 'Error occurred in delete share link';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
       }
       }
-
-    });
-
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 62 - 28
apps/app/src/server/routes/apiv3/slack-integration-legacy-settings.js

@@ -1,9 +1,9 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import express from 'express';
 import express from 'express';
 import { body } from 'express-validator';
 import { body } from 'express-validator';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -11,17 +11,24 @@ import loggerFactory from '~/utils/logger';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
-
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:routes:apiv3:slack-integration-legacy-setting');
+const logger = loggerFactory(
+  'growi:routes:apiv3:slack-integration-legacy-setting',
+);
 
 
 const router = express.Router();
 const router = express.Router();
 
 
 const validator = {
 const validator = {
   slackConfiguration: [
   slackConfiguration: [
-    body('webhookUrl').if(value => value != null).isString().trim(),
+    body('webhookUrl')
+      .if((value) => value != null)
+      .isString()
+      .trim(),
     body('isIncomingWebhookPrioritized').isBoolean(),
     body('isIncomingWebhookPrioritized').isBoolean(),
-    body('slackToken').if(value => value != null).isString().trim(),
+    body('slackToken')
+      .if((value) => value != null)
+      .isString()
+      .trim(),
   ],
   ],
 };
 };
 
 
@@ -45,7 +52,9 @@ const validator = {
  */
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
 
@@ -77,16 +86,26 @@ module.exports = (crowi) => {
    *                              type: boolean
    *                              type: boolean
    *                              description: whether slackbot is configured
    *                              description: whether slackbot is configured
    */
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.LEGACY_SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, async(req, res) => {
-
-    const slackIntegrationParams = {
-      isSlackbotConfigured: crowi.slackIntegrationService.isSlackbotConfigured,
-      webhookUrl: await crowi.configManager.getConfig('slack:incomingWebhookUrl'),
-      isIncomingWebhookPrioritized: await crowi.configManager.getConfig('slack:isIncomingWebhookPrioritized'),
-      slackToken: await crowi.configManager.getConfig('slack:token'),
-    };
-    return res.apiv3({ slackIntegrationParams });
-  });
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.LEGACY_SLACK_INTEGRATION]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      const slackIntegrationParams = {
+        isSlackbotConfigured:
+          crowi.slackIntegrationService.isSlackbotConfigured,
+        webhookUrl: await crowi.configManager.getConfig(
+          'slack:incomingWebhookUrl',
+        ),
+        isIncomingWebhookPrioritized: await crowi.configManager.getConfig(
+          'slack:isIncomingWebhookPrioritized',
+        ),
+        slackToken: await crowi.configManager.getConfig('slack:token'),
+      };
+      return res.apiv3({ slackIntegrationParams });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -123,35 +142,50 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      $ref: '#/components/schemas/SlackConfigurationParams'
    *                      $ref: '#/components/schemas/SlackConfigurationParams'
    */
    */
-  router.put('/', accessTokenParser([SCOPE.WRITE.ADMIN.LEGACY_SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.slackConfiguration, apiV3FormValidator, async(req, res) => {
-
+  router.put(
+    '/',
+    accessTokenParser([SCOPE.WRITE.ADMIN.LEGACY_SLACK_INTEGRATION]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.slackConfiguration,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
       const requestParams = {
         'slack:incomingWebhookUrl': req.body.webhookUrl,
         'slack:incomingWebhookUrl': req.body.webhookUrl,
-        'slack:isIncomingWebhookPrioritized': req.body.isIncomingWebhookPrioritized,
+        'slack:isIncomingWebhookPrioritized':
+          req.body.isIncomingWebhookPrioritized,
         'slack:token': req.body.slackToken,
         'slack:token': req.body.slackToken,
       };
       };
 
 
       try {
       try {
         await configManager.updateConfigs(requestParams);
         await configManager.updateConfigs(requestParams);
         const responseParams = {
         const responseParams = {
-          webhookUrl: await crowi.configManager.getConfig('slack:incomingWebhookUrl'),
-          isIncomingWebhookPrioritized: await crowi.configManager.getConfig('slack:isIncomingWebhookPrioritized'),
+          webhookUrl: await crowi.configManager.getConfig(
+            'slack:incomingWebhookUrl',
+          ),
+          isIncomingWebhookPrioritized: await crowi.configManager.getConfig(
+            'slack:isIncomingWebhookPrioritized',
+          ),
           slackToken: await crowi.configManager.getConfig('slack:token'),
           slackToken: await crowi.configManager.getConfig('slack:token'),
         };
         };
 
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE };
+        const parameters = {
+          action:
+            SupportedAction.ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ responseParams });
         return res.apiv3({ responseParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating slack configuration';
         const msg = 'Error occurred in updating slack configuration';
         logger.error('Error', err);
         logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'update-slackConfiguration-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'update-slackConfiguration-failed'),
+        );
       }
       }
-
-    });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

Разница между файлами не показана из-за своего большого размера
+ 490 - 271
apps/app/src/server/routes/apiv3/slack-integration-settings.js


+ 315 - 153
apps/app/src/server/routes/apiv3/slack-integration.js

@@ -20,14 +20,12 @@ import loggerFactory from '~/utils/logger';
 import { handleError } from '../../service/slack-command-handler/error-handler';
 import { handleError } from '../../service/slack-command-handler/error-handler';
 import { checkPermission } from '../../util/slack-integration';
 import { checkPermission } from '../../util/slack-integration';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const router = express.Router();
 const router = express.Router();
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
 
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-
   const { slackIntegrationService } = crowi;
   const { slackIntegrationService } = crowi;
 
 
   // Check if the access token is correct
   // Check if the access token is correct
@@ -35,12 +33,15 @@ module.exports = (crowi) => {
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
 
 
     if (tokenPtoG == null) {
     if (tokenPtoG == null) {
-      const message = 'The value of header \'x-growi-ptog-tokens\' must not be empty.';
+      const message =
+        "The value of header 'x-growi-ptog-tokens' must not be empty.";
       logger.warn(message, { body: req.body });
       logger.warn(message, { body: req.body });
       return next(createError(400, message));
       return next(createError(400, message));
     }
     }
 
 
-    const SlackAppIntegrationCount = await SlackAppIntegration.countDocuments({ tokenPtoG });
+    const SlackAppIntegrationCount = await SlackAppIntegration.countDocuments({
+      tokenPtoG,
+    });
 
 
     logger.debug('verifyAccessTokenFromProxy', {
     logger.debug('verifyAccessTokenFromProxy', {
       tokenPtoG,
       tokenPtoG,
@@ -49,9 +50,10 @@ module.exports = (crowi) => {
 
 
     if (SlackAppIntegrationCount === 0) {
     if (SlackAppIntegrationCount === 0) {
       return res.status(403).send({
       return res.status(403).send({
-        message: 'The access token that identifies the request source is slackbot-proxy is invalid. Did you setup with `/growi register`.\n'
-        + 'Or did you delete registration for GROWI ? if so, the link with GROWI has been disconnected. '
-        + 'Please unregister the information registered in the proxy and setup `/growi register` again.',
+        message:
+          'The access token that identifies the request source is slackbot-proxy is invalid. Did you setup with `/growi register`.\n' +
+          'Or did you delete registration for GROWI ? if so, the link with GROWI has been disconnected. ' +
+          'Please unregister the information registered in the proxy and setup `/growi register` again.',
       });
       });
     }
     }
 
 
@@ -59,12 +61,19 @@ module.exports = (crowi) => {
   }
   }
 
 
   async function extractPermissionsCommands(tokenPtoG) {
   async function extractPermissionsCommands(tokenPtoG) {
-    const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+    const slackAppIntegration = await SlackAppIntegration.findOne({
+      tokenPtoG,
+    });
     if (slackAppIntegration == null) return null;
     if (slackAppIntegration == null) return null;
-    const permissionsForBroadcastUseCommands = slackAppIntegration.permissionsForBroadcastUseCommands;
-    const permissionsForSingleUseCommands = slackAppIntegration.permissionsForSingleUseCommands;
-
-    return { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands };
+    const permissionsForBroadcastUseCommands =
+      slackAppIntegration.permissionsForBroadcastUseCommands;
+    const permissionsForSingleUseCommands =
+      slackAppIntegration.permissionsForSingleUseCommands;
+
+    return {
+      permissionsForBroadcastUseCommands,
+      permissionsForSingleUseCommands,
+    };
   }
   }
 
 
   // TODO: move this middleware to each controller
   // TODO: move this middleware to each controller
@@ -78,8 +87,7 @@ module.exports = (crowi) => {
     let growiCommand;
     let growiCommand;
     try {
     try {
       growiCommand = getGrowiCommand(req.body);
       growiCommand = getGrowiCommand(req.body);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err.message);
       logger.error(err.message);
       return next(err);
       return next(err);
     }
     }
@@ -92,11 +100,15 @@ module.exports = (crowi) => {
           blocks: [
           blocks: [
             markdownSectionBlock('*Command is not supported*'),
             markdownSectionBlock('*Command is not supported*'),
             // eslint-disable-next-line max-len
             // eslint-disable-next-line max-len
-            markdownSectionBlock(`\`/growi ${growiCommand.growiCommandType}\` command is not supported in this version of GROWI bot. Run \`/growi help\` to see all supported commands.`),
+            markdownSectionBlock(
+              `\`/growi ${growiCommand.growiCommandType}\` command is not supported in this version of GROWI bot. Run \`/growi help\` to see all supported commands.`,
+            ),
           ],
           ],
         },
         },
       };
       };
-      return next(new SlackCommandHandlerError('Command type is not specified', options));
+      return next(
+        new SlackCommandHandlerError('Command type is not specified', options),
+      );
     }
     }
 
 
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
@@ -108,19 +120,41 @@ module.exports = (crowi) => {
     const siteUrl = growiInfoService.getSiteUrl();
     const siteUrl = growiInfoService.getSiteUrl();
 
 
     let commandPermission;
     let commandPermission;
-    if (extractPermissions != null) { // with proxy
-      const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = extractPermissions;
-      commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
-      const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
+    if (extractPermissions != null) {
+      // with proxy
+      const {
+        permissionsForBroadcastUseCommands,
+        permissionsForSingleUseCommands,
+      } = extractPermissions;
+      commandPermission = Object.fromEntries([
+        ...permissionsForBroadcastUseCommands,
+        ...permissionsForSingleUseCommands,
+      ]);
+      const isPermitted = checkPermission(
+        commandPermission,
+        growiCommand.growiCommandType,
+        fromChannel,
+      );
       if (isPermitted) return next();
       if (isPermitted) return next();
 
 
-      return next(createError(403, `It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`));
+      return next(
+        createError(
+          403,
+          `It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`,
+        ),
+      );
     }
     }
 
 
     // without proxy
     // without proxy
-    commandPermission = configManager.getConfig('slackbot:withoutProxy:commandPermission');
-
-    const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
+    commandPermission = configManager.getConfig(
+      'slackbot:withoutProxy:commandPermission',
+    );
+
+    const isPermitted = checkPermission(
+      commandPermission,
+      growiCommand.growiCommandType,
+      fromChannel,
+    );
     if (isPermitted) {
     if (isPermitted) {
       return next();
       return next();
     }
     }
@@ -130,11 +164,15 @@ module.exports = (crowi) => {
         text: 'Command forbidden',
         text: 'Command forbidden',
         blocks: [
         blocks: [
           markdownSectionBlock('*Command is not supported*'),
           markdownSectionBlock('*Command is not supported*'),
-          markdownSectionBlock(`It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`),
+          markdownSectionBlock(
+            `It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`,
+          ),
         ],
         ],
       },
       },
     };
     };
-    return next(new SlackCommandHandlerError('Command type is not specified', options));
+    return next(
+      new SlackCommandHandlerError('Command type is not specified', options),
+    );
   }
   }
 
 
   // TODO: move this middleware to each controller
   // TODO: move this middleware to each controller
@@ -148,26 +186,49 @@ module.exports = (crowi) => {
     const { interactionPayloadAccessor } = req;
     const { interactionPayloadAccessor } = req;
     const siteUrl = growiInfoService.getSiteUrl();
     const siteUrl = growiInfoService.getSiteUrl();
 
 
-    const { actionId, callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
+    const { actionId, callbackId } =
+      interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const callbacIdkOrActionId = callbackId || actionId;
     const callbacIdkOrActionId = callbackId || actionId;
     const fromChannel = interactionPayloadAccessor.getChannel();
     const fromChannel = interactionPayloadAccessor.getChannel();
 
 
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const extractPermissions = await extractPermissionsCommands(tokenPtoG);
     const extractPermissions = await extractPermissionsCommands(tokenPtoG);
     let commandPermission;
     let commandPermission;
-    if (extractPermissions != null) { // with proxy
-      const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = extractPermissions;
-      commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
-      const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
+    if (extractPermissions != null) {
+      // with proxy
+      const {
+        permissionsForBroadcastUseCommands,
+        permissionsForSingleUseCommands,
+      } = extractPermissions;
+      commandPermission = Object.fromEntries([
+        ...permissionsForBroadcastUseCommands,
+        ...permissionsForSingleUseCommands,
+      ]);
+      const isPermitted = checkPermission(
+        commandPermission,
+        callbacIdkOrActionId,
+        fromChannel,
+      );
       if (isPermitted) return next();
       if (isPermitted) return next();
 
 
-      return next(createError(403, `This interaction is forbidden on this GROWI: ${siteUrl}`));
+      return next(
+        createError(
+          403,
+          `This interaction is forbidden on this GROWI: ${siteUrl}`,
+        ),
+      );
     }
     }
 
 
     // without proxy
     // without proxy
-    commandPermission = configManager.getConfig('slackbot:withoutProxy:commandPermission');
-
-    const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
+    commandPermission = configManager.getConfig(
+      'slackbot:withoutProxy:commandPermission',
+    );
+
+    const isPermitted = checkPermission(
+      commandPermission,
+      callbacIdkOrActionId,
+      fromChannel,
+    );
     if (isPermitted) {
     if (isPermitted) {
       return next();
       return next();
     }
     }
@@ -177,7 +238,9 @@ module.exports = (crowi) => {
         text: 'Interaction forbidden',
         text: 'Interaction forbidden',
         blocks: [
         blocks: [
           markdownSectionBlock('*Interaction forbidden*'),
           markdownSectionBlock('*Interaction forbidden*'),
-          markdownSectionBlock(`This interaction is forbidden on this GROWI: ${siteUrl}`),
+          markdownSectionBlock(
+            `This interaction is forbidden on this GROWI: ${siteUrl}`,
+          ),
         ],
         ],
       },
       },
     };
     };
@@ -185,7 +248,9 @@ module.exports = (crowi) => {
   }
   }
 
 
   const addSigningSecretToReq = (req, res, next) => {
   const addSigningSecretToReq = (req, res, next) => {
-    req.slackSigningSecret = configManager.getConfig('slackbot:withoutProxy:signingSecret');
+    req.slackSigningSecret = configManager.getConfig(
+      'slackbot:withoutProxy:signingSecret',
+    );
     return next();
     return next();
   };
   };
 
 
@@ -203,11 +268,15 @@ module.exports = (crowi) => {
 
 
   const parseSlackInteractionRequest = (req, res, next) => {
   const parseSlackInteractionRequest = (req, res, next) => {
     if (req.body.payload == null) {
     if (req.body.payload == null) {
-      return next(new Error('The payload is not in the request from slack or proxy.'));
+      return next(
+        new Error('The payload is not in the request from slack or proxy.'),
+      );
     }
     }
 
 
     req.interactionPayload = JSON.parse(req.body.payload);
     req.interactionPayload = JSON.parse(req.body.payload);
-    req.interactionPayloadAccessor = new InteractionPayloadAccessor(req.interactionPayload);
+    req.interactionPayloadAccessor = new InteractionPayloadAccessor(
+      req.interactionPayload,
+    );
 
 
     return next();
     return next();
   };
   };
@@ -229,19 +298,23 @@ module.exports = (crowi) => {
     if (growiCommand == null) {
     if (growiCommand == null) {
       try {
       try {
         growiCommand = parseSlashCommand(body);
         growiCommand = parseSlashCommand(body);
-      }
-      catch (err) {
+      } catch (err) {
         if (err instanceof InvalidGrowiCommandError) {
         if (err instanceof InvalidGrowiCommandError) {
           const options = {
           const options = {
             respondBody: {
             respondBody: {
               text: 'Command type is not specified',
               text: 'Command type is not specified',
               blocks: [
               blocks: [
                 markdownSectionBlock('*Command type is not specified.*'),
                 markdownSectionBlock('*Command type is not specified.*'),
-                markdownSectionBlock('Run `/growi help` to check the commands you can use.'),
+                markdownSectionBlock(
+                  'Run `/growi help` to check the commands you can use.',
+                ),
               ],
               ],
             },
             },
           };
           };
-          throw new SlackCommandHandlerError('Command type is not specified', options);
+          throw new SlackCommandHandlerError(
+            'Command type is not specified',
+            options,
+          );
         }
         }
         throw err;
         throw err;
       }
       }
@@ -255,8 +328,7 @@ module.exports = (crowi) => {
     try {
     try {
       growiCommand = getGrowiCommand(body);
       growiCommand = getGrowiCommand(body);
       respondUtil = getRespondUtil(responseUrl);
       respondUtil = getRespondUtil(responseUrl);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err.message);
       logger.error(err.message);
       return handleError(err, responseUrl);
       return handleError(err, responseUrl);
     }
     }
@@ -274,22 +346,26 @@ module.exports = (crowi) => {
       await respondUtil.respond({
       await respondUtil.respond({
         text: 'Processing your request ...',
         text: 'Processing your request ...',
         blocks: [
         blocks: [
-          markdownSectionBlock(`Processing your request *"/growi ${growiCommand.growiCommandType}"* on GROWI at ${appSiteUrl} ...`),
+          markdownSectionBlock(
+            `Processing your request *"/growi ${growiCommand.growiCommandType}"* on GROWI at ${appSiteUrl} ...`,
+          ),
         ],
         ],
       });
       });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Error occurred while request via axios:', err);
       logger.error('Error occurred while request via axios:', err);
     }
     }
 
 
     try {
     try {
-      await slackIntegrationService.handleCommandRequest(growiCommand, client, body, respondUtil);
-    }
-    catch (err) {
+      await slackIntegrationService.handleCommandRequest(
+        growiCommand,
+        client,
+        body,
+        respondUtil,
+      );
+    } catch (err) {
       logger.error(err.message);
       logger.error(err.message);
       return handleError(err, responseUrl);
       return handleError(err, responseUrl);
     }
     }
-
   }
   }
 
 
   // TODO: this method will be a middleware when typescriptize in the future
   // TODO: this method will be a middleware when typescriptize in the future
@@ -335,21 +411,27 @@ module.exports = (crowi) => {
    *               type: string
    *               type: string
    *               example: "No text."
    *               example: "No text."
    */
    */
-  router.post('/commands', addSigningSecretToReq, verifySlackRequest, checkCommandsPermission, async(req, res) => {
-    const { body } = req;
-    const responseUrl = getResponseUrl(req);
-
-    let client;
-    try {
-      client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
-    }
-    catch (err) {
-      logger.error(err.message);
-      return handleError(err, responseUrl);
-    }
+  router.post(
+    '/commands',
+    addSigningSecretToReq,
+    verifySlackRequest,
+    checkCommandsPermission,
+    async (req, res) => {
+      const { body } = req;
+      const responseUrl = getResponseUrl(req);
+
+      let client;
+      try {
+        client =
+          await slackIntegrationService.generateClientForCustomBotWithoutProxy();
+      } catch (err) {
+        logger.error(err.message);
+        return handleError(err, responseUrl);
+      }
 
 
-    return handleCommands(body, res, client, responseUrl);
-  });
+      return handleCommands(body, res, client, responseUrl);
+    },
+  );
 
 
   // when relation test
   // when relation test
   /**
   /**
@@ -384,15 +466,19 @@ module.exports = (crowi) => {
    *                 challenge:
    *                 challenge:
    *                   type: string
    *                   type: string
    */
    */
-  router.post('/proxied/verify', verifyAccessTokenFromProxy, async(req, res) => {
-    const { body } = req;
-
-    // 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 res.send({ challenge: body.challenge });
-    }
-  });
+  router.post(
+    '/proxied/verify',
+    verifyAccessTokenFromProxy,
+    async (req, res) => {
+      const { body } = req;
+
+      // 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 res.send({ challenge: body.challenge });
+      }
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -419,26 +505,30 @@ module.exports = (crowi) => {
    *               type: string
    *               type: string
    *               example: "No text."
    *               example: "No text."
    */
    */
-  router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandsPermission, async(req, res) => {
-    const { body } = req;
-    const responseUrl = getResponseUrl(req);
+  router.post(
+    '/proxied/commands',
+    verifyAccessTokenFromProxy,
+    checkCommandsPermission,
+    async (req, res) => {
+      const { body } = req;
+      const responseUrl = getResponseUrl(req);
 
 
-    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+      const tokenPtoG = req.headers['x-growi-ptog-tokens'];
 
 
-    let client;
-    try {
-      client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
-    }
-    catch (err) {
-      logger.error(err.message);
-      return handleError(err, responseUrl);
-    }
+      let client;
+      try {
+        client =
+          await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
+      } catch (err) {
+        logger.error(err.message);
+        return handleError(err, responseUrl);
+      }
 
 
-    return handleCommands(body, res, client, responseUrl);
-  });
+      return handleCommands(body, res, client, responseUrl);
+    },
+  );
 
 
   async function handleInteractionsRequest(req, res, client) {
   async function handleInteractionsRequest(req, res, client) {
-
     const { interactionPayload, interactionPayloadAccessor } = req;
     const { interactionPayload, interactionPayloadAccessor } = req;
     const { type } = interactionPayload;
     const { type } = interactionPayload;
     const responseUrl = interactionPayloadAccessor.getResponseUrl();
     const responseUrl = interactionPayloadAccessor.getResponseUrl();
@@ -447,16 +537,25 @@ module.exports = (crowi) => {
       const respondUtil = getRespondUtil(responseUrl);
       const respondUtil = getRespondUtil(responseUrl);
       switch (type) {
       switch (type) {
         case 'block_actions':
         case 'block_actions':
-          await slackIntegrationService.handleBlockActionsRequest(client, interactionPayload, interactionPayloadAccessor, respondUtil);
+          await slackIntegrationService.handleBlockActionsRequest(
+            client,
+            interactionPayload,
+            interactionPayloadAccessor,
+            respondUtil,
+          );
           break;
           break;
         case 'view_submission':
         case 'view_submission':
-          await slackIntegrationService.handleViewSubmissionRequest(client, interactionPayload, interactionPayloadAccessor, respondUtil);
+          await slackIntegrationService.handleViewSubmissionRequest(
+            client,
+            interactionPayload,
+            interactionPayloadAccessor,
+            respondUtil,
+          );
           break;
           break;
         default:
         default:
           break;
           break;
       }
       }
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       return handleError(err, responseUrl);
       return handleError(err, responseUrl);
     }
     }
@@ -482,10 +581,18 @@ module.exports = (crowi) => {
    *       200:
    *       200:
    *         description: OK
    *         description: OK
    */
    */
-  router.post('/interactions', addSigningSecretToReq, verifySlackRequest, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
-    const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
-    return handleInteractionsRequest(req, res, client);
-  });
+  router.post(
+    '/interactions',
+    addSigningSecretToReq,
+    verifySlackRequest,
+    parseSlackInteractionRequest,
+    checkInteractionsPermission,
+    async (req, res) => {
+      const client =
+        await slackIntegrationService.generateClientForCustomBotWithoutProxy();
+      return handleInteractionsRequest(req, res, client);
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -507,11 +614,18 @@ module.exports = (crowi) => {
    *       200:
    *       200:
    *         description: OK
    *         description: OK
    */
    */
-  router.post('/proxied/interactions', verifyAccessTokenFromProxy, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
-    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
-    const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
-    return handleInteractionsRequest(req, res, client);
-  });
+  router.post(
+    '/proxied/interactions',
+    verifyAccessTokenFromProxy,
+    parseSlackInteractionRequest,
+    checkInteractionsPermission,
+    async (req, res) => {
+      const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+      const client =
+        await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
+      return handleInteractionsRequest(req, res, client);
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -540,13 +654,25 @@ module.exports = (crowi) => {
    *                   items:
    *                   items:
    *                     type: object
    *                     type: object
    */
    */
-  router.get('/supported-commands', verifyAccessTokenFromProxy, async(req, res) => {
-    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
-    const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
-    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = slackAppIntegration;
-
-    return res.apiv3({ permissionsForBroadcastUseCommands, permissionsForSingleUseCommands });
-  });
+  router.get(
+    '/supported-commands',
+    verifyAccessTokenFromProxy,
+    async (req, res) => {
+      const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+      const slackAppIntegration = await SlackAppIntegration.findOne({
+        tokenPtoG,
+      });
+      const {
+        permissionsForBroadcastUseCommands,
+        permissionsForSingleUseCommands,
+      } = slackAppIntegration;
+
+      return res.apiv3({
+        permissionsForBroadcastUseCommands,
+        permissionsForSingleUseCommands,
+      });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -575,28 +701,46 @@ module.exports = (crowi) => {
    *             schema:
    *             schema:
    *               type: object
    *               type: object
    */
    */
-  router.post('/events', verifyUrlMiddleware, addSigningSecretToReq, verifySlackRequest, async(req, res) => {
-    const { event } = req.body;
-
-    const growiBotEvent = {
-      eventType: event.type,
-      event,
-    };
-
-    try {
-      const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
-      // convert permission object to map
-      const permission = new Map(Object.entries(crowi.configManager.getConfig('slackbot:withoutProxy:eventActionsPermission')));
-
-      await crowi.slackIntegrationService.handleEventsRequest(client, growiBotEvent, permission);
+  router.post(
+    '/events',
+    verifyUrlMiddleware,
+    addSigningSecretToReq,
+    verifySlackRequest,
+    async (req, res) => {
+      const { event } = req.body;
+
+      const growiBotEvent = {
+        eventType: event.type,
+        event,
+      };
 
 
-      return res.apiv3({});
-    }
-    catch (err) {
-      logger.error('Error occurred while handling event request.', err);
-      return res.apiv3Err(new ErrorV3('Error occurred while handling event request.'));
-    }
-  });
+      try {
+        const client =
+          await slackIntegrationService.generateClientForCustomBotWithoutProxy();
+        // convert permission object to map
+        const permission = new Map(
+          Object.entries(
+            crowi.configManager.getConfig(
+              'slackbot:withoutProxy:eventActionsPermission',
+            ),
+          ),
+        );
+
+        await crowi.slackIntegrationService.handleEventsRequest(
+          client,
+          growiBotEvent,
+          permission,
+        );
+
+        return res.apiv3({});
+      } catch (err) {
+        logger.error('Error occurred while handling event request.', err);
+        return res.apiv3Err(
+          new ErrorV3('Error occurred while handling event request.'),
+        );
+      }
+    },
+  );
 
 
   const validator = {
   const validator = {
     validateEventRequest: [
     validateEventRequest: [
@@ -634,33 +778,51 @@ module.exports = (crowi) => {
    *             schema:
    *             schema:
    *               type: object
    *               type: object
    */
    */
-  router.post('/proxied/events', verifyAccessTokenFromProxy, validator.validateEventRequest, async(req, res) => {
-    const { growiBotEvent, data } = req.body;
+  router.post(
+    '/proxied/events',
+    verifyAccessTokenFromProxy,
+    validator.validateEventRequest,
+    async (req, res) => {
+      const { growiBotEvent, data } = req.body;
 
 
-    try {
-      const tokenPtoG = req.headers['x-growi-ptog-tokens'];
-      const SlackAppIntegration = mongoose.model('SlackAppIntegration');
-      const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+      try {
+        const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+        const SlackAppIntegration = mongoose.model('SlackAppIntegration');
+        const slackAppIntegration = await SlackAppIntegration.findOne({
+          tokenPtoG,
+        });
+
+        if (slackAppIntegration == null) {
+          throw new Error(
+            'No SlackAppIntegration exists that corresponds to the tokenPtoG specified.',
+          );
+        }
 
 
-      if (slackAppIntegration == null) {
-        throw new Error('No SlackAppIntegration exists that corresponds to the tokenPtoG specified.');
+        const client =
+          await slackIntegrationService.generateClientBySlackAppIntegration(
+            slackAppIntegration,
+          );
+        const { permissionsForSlackEventActions } = slackAppIntegration;
+
+        await slackIntegrationService.handleEventsRequest(
+          client,
+          growiBotEvent,
+          permissionsForSlackEventActions,
+          data,
+        );
+
+        return res.apiv3({});
+      } catch (err) {
+        logger.error('Error occurred while handling event request.', err);
+        return res.apiv3Err(
+          new ErrorV3('Error occurred while handling event request.'),
+        );
       }
       }
-
-      const client = await slackIntegrationService.generateClientBySlackAppIntegration(slackAppIntegration);
-      const { permissionsForSlackEventActions } = slackAppIntegration;
-
-      await slackIntegrationService.handleEventsRequest(client, growiBotEvent, permissionsForSlackEventActions, data);
-
-      return res.apiv3({});
-    }
-    catch (err) {
-      logger.error('Error occurred while handling event request.', err);
-      return res.apiv3Err(new ErrorV3('Error occurred while handling event request.'));
-    }
-  });
+    },
+  );
 
 
   // error handler
   // error handler
-  router.use(async(err, req, res, next) => {
+  router.use(async (err, req, res, next) => {
     const responseUrl = getResponseUrl(req);
     const responseUrl = getResponseUrl(req);
     if (responseUrl == null) {
     if (responseUrl == null) {
       // pass err to global error handler
       // pass err to global error handler

+ 10 - 12
apps/app/src/server/routes/apiv3/staffs.js

@@ -1,13 +1,12 @@
-import axios from 'axios';
 import { addHours } from 'date-fns/addHours';
 import { addHours } from 'date-fns/addHours';
 import { isAfter } from 'date-fns/isAfter';
 import { isAfter } from 'date-fns/isAfter';
 import { Router } from 'express';
 import { Router } from 'express';
 
 
+import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:routes:apiv3:staffs'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:staffs'); // eslint-disable-line no-unused-vars
 
 
-
 const router = Router();
 const router = Router();
 
 
 const contributors = require('^/resource/Contributor');
 const contributors = require('^/resource/Contributor');
@@ -17,18 +16,19 @@ const contributorsCache = contributors;
 let gcContributors;
 let gcContributors;
 
 
 // Sorting contributors by this method
 // Sorting contributors by this method
-const compareFunction = function(a, b) {
-  return a.order - b.order;
-};
+const compareFunction = (a, b) => a.order - b.order;
 
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-
-  router.get('/', async(req, res) => {
+  router.get('/', async (req, res) => {
     const now = new Date();
     const now = new Date();
-    const growiCloudUri = await crowi.configManager.getConfig('app:growiCloudUri');
+    const growiCloudUri =
+      await crowi.configManager.getConfig('app:growiCloudUri');
 
 
-    if (growiCloudUri != null && (expiredAt == null || isAfter(now, expiredAt))) {
+    if (
+      growiCloudUri != null &&
+      (expiredAt == null || isAfter(now, expiredAt))
+    ) {
       const url = new URL('_api/staffCredit', growiCloudUri);
       const url = new URL('_api/staffCredit', growiCloudUri);
       try {
       try {
         const gcContributorsRes = await axios.get(url.toString());
         const gcContributorsRes = await axios.get(url.toString());
@@ -41,8 +41,7 @@ module.exports = (crowi) => {
         contributorsCache.sort(compareFunction);
         contributorsCache.sort(compareFunction);
         // caching 'expiredAt' for 1 hour
         // caching 'expiredAt' for 1 hour
         expiredAt = addHours(now, 1);
         expiredAt = addHours(now, 1);
-      }
-      catch (err) {
+      } catch (err) {
         logger.warn('Getting GROWI.cloud staffcredit is failed');
         logger.warn('Getting GROWI.cloud staffcredit is failed');
       }
       }
     }
     }
@@ -50,5 +49,4 @@ module.exports = (crowi) => {
   });
   });
 
 
   return router;
   return router;
-
 };
 };

+ 45 - 39
apps/app/src/server/routes/apiv3/statistics.js

@@ -24,47 +24,46 @@ const USER_STATUS_MASTER = {
  *         type: object
  *         type: object
  *         properties:
  *         properties:
  *           data:
  *           data:
-*             type: object
-*             properties:
-*               total:
-*                 type: integer
-*                 example: 1
-*               active:
-*                 type: object
-*                 properties:
-*                   total:
-*                     type: integer
-*                     example: 1
-*                   admin:
-*                     type: integer
-*                     example: 1
-*               inactive:
-*                 type: object
-*                 properties:
-*                   total:
-*                     type: integer
-*                     example: 0
-*                   registered:
-*                     type: integer
-*                     example: 0
-*                   suspended:
-*                     type: integer
-*                     example: 0
-*                   deleted:
-*                     type: integer
-*                     example: 0
-*                   invited:
-*                     type: integer
-*                     example: 0
-*/
+ *             type: object
+ *             properties:
+ *               total:
+ *                 type: integer
+ *                 example: 1
+ *               active:
+ *                 type: object
+ *                 properties:
+ *                   total:
+ *                     type: integer
+ *                     example: 1
+ *                   admin:
+ *                     type: integer
+ *                     example: 1
+ *               inactive:
+ *                 type: object
+ *                 properties:
+ *                   total:
+ *                     type: integer
+ *                     example: 0
+ *                   registered:
+ *                     type: integer
+ *                     example: 0
+ *                   suspended:
+ *                     type: integer
+ *                     example: 0
+ *                   deleted:
+ *                     type: integer
+ *                     example: 0
+ *                   invited:
+ *                     type: integer
+ *                     example: 0
+ */
 
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-
   const models = crowi.models;
   const models = crowi.models;
   const User = models.User;
   const User = models.User;
 
 
-  const getUserStatistics = async() => {
+  const getUserStatistics = async () => {
     const userCountGroupByStatus = await User.aggregate().group({
     const userCountGroupByStatus = await User.aggregate().group({
       _id: '$status',
       _id: '$status',
       totalCount: { $sum: 1 },
       totalCount: { $sum: 1 },
@@ -86,7 +85,11 @@ module.exports = (crowi) => {
     delete userCountResults.active;
     delete userCountResults.active;
 
 
     // Calculate the total number of inactive users
     // Calculate the total number of inactive users
-    const inactiveUserTotal = userCountResults.invited + userCountResults.deleted + userCountResults.suspended + userCountResults.registered;
+    const inactiveUserTotal =
+      userCountResults.invited +
+      userCountResults.deleted +
+      userCountResults.suspended +
+      userCountResults.registered;
 
 
     // Get admin users
     // Get admin users
     const adminUsers = await User.findAdmins();
     const adminUsers = await User.findAdmins();
@@ -104,7 +107,7 @@ module.exports = (crowi) => {
     };
     };
   };
   };
 
 
-  const getUserStatisticsForNotLoggedIn = async() => {
+  const getUserStatisticsForNotLoggedIn = async () => {
     const data = await getUserStatistics();
     const data = await getUserStatistics();
     delete data.active.admin;
     delete data.active.admin;
     delete data.inactive.invited;
     delete data.inactive.invited;
@@ -132,8 +135,11 @@ module.exports = (crowi) => {
    *                description: Statistics for all user
    *                description: Statistics for all user
    *                $ref: '#/components/schemas/StatisticsUserResponse'
    *                $ref: '#/components/schemas/StatisticsUserResponse'
    */
    */
-  router.get('/user', noCache(), async(req, res) => {
-    const data = req.user == null ? await getUserStatisticsForNotLoggedIn() : await getUserStatistics();
+  router.get('/user', noCache(), async (req, res) => {
+    const data =
+      req.user == null
+        ? await getUserStatisticsForNotLoggedIn()
+        : await getUserStatistics();
     res.status(200).send({ data });
     res.status(200).send({ data });
   });
   });
 
 

+ 44 - 21
apps/app/src/server/routes/apiv3/user-group-relation.js

@@ -1,7 +1,7 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import express from 'express';
 import express from 'express';
 
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import UserGroupRelation from '~/server/models/user-group-relation';
@@ -17,12 +17,16 @@ const validator = {};
 
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
 
 
   validator.list = [
   validator.list = [
     query('groupIds', 'groupIds is required and must be an array').isArray(),
     query('groupIds', 'groupIds is required and must be an array').isArray(),
-    query('childGroupIds', 'childGroupIds must be an array').optional().isArray(),
+    query('childGroupIds', 'childGroupIds must be an array')
+      .optional()
+      .isArray(),
   ];
   ];
 
 
   /**
   /**
@@ -55,28 +59,47 @@ module.exports = (crowi) => {
    *                          items:
    *                          items:
    *                            type: object
    *                            type: object
    */
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, validator.list, async(req, res) => {
-    const { query } = req;
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.list,
+    async (req, res) => {
+      const { query } = req;
 
 
-    try {
-      const relations = await UserGroupRelation.find({ relatedGroup: { $in: query.groupIds } }).populate('relatedUser');
+      try {
+        const relations = await UserGroupRelation.find({
+          relatedGroup: { $in: query.groupIds },
+        }).populate('relatedUser');
 
 
-      let relationsOfChildGroups = null;
-      if (Array.isArray(query.childGroupIds)) {
-        const _relationsOfChildGroups = await UserGroupRelation.find({ relatedGroup: { $in: query.childGroupIds } }).populate('relatedUser');
-        relationsOfChildGroups = _relationsOfChildGroups.map(relation => serializeUserGroupRelationSecurely(relation)); // serialize
-      }
+        let relationsOfChildGroups = null;
+        if (Array.isArray(query.childGroupIds)) {
+          const _relationsOfChildGroups = await UserGroupRelation.find({
+            relatedGroup: { $in: query.childGroupIds },
+          }).populate('relatedUser');
+          relationsOfChildGroups = _relationsOfChildGroups.map((relation) =>
+            serializeUserGroupRelationSecurely(relation),
+          ); // serialize
+        }
 
 
-      const serialized = relations.map(relation => serializeUserGroupRelationSecurely(relation));
+        const serialized = relations.map((relation) =>
+          serializeUserGroupRelationSecurely(relation),
+        );
 
 
-      return res.apiv3({ userGroupRelations: serialized, relationsOfChildGroups });
-    }
-    catch (err) {
-      const msg = 'Error occurred in fetching user group relations';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-group-relation-list-fetch-failed'));
-    }
-  });
+        return res.apiv3({
+          userGroupRelations: serialized,
+          relationsOfChildGroups,
+        });
+      } catch (err) {
+        const msg = 'Error occurred in fetching user group relations';
+        logger.error('Error', err);
+        return res.apiv3Err(
+          new ErrorV3(msg, 'user-group-relation-list-fetch-failed'),
+        );
+      }
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 349 - 192
apps/app/src/server/routes/apiv3/user-group.js

@@ -1,54 +1,61 @@
 import { GroupType } from '@growi/core';
 import { GroupType } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import express from 'express';
 import express from 'express';
-import {
-  body, param, query, sanitizeQuery,
-} from 'express-validator';
+import { body, param, query, sanitizeQuery } from 'express-validator';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import UserGroup from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
-import { toPagingLimit, toPagingOffset } from '~/server/util/express-validator/sanitizer';
+import {
+  toPagingLimit,
+  toPagingOffset,
+} from '~/server/util/express-validator/sanitizer';
 import { generalXssFilter } from '~/services/general-xss-filter';
 import { generalXssFilter } from '~/services/general-xss-filter';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:user-group'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:user-group'); // eslint-disable-line no-unused-vars
 
 
 const router = express.Router();
 const router = express.Router();
 
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
-  const {
-    User,
-    Page,
-  } = crowi.models;
+  const { User, Page } = crowi.models;
 
 
   const validator = {
   const validator = {
     create: [
     create: [
-      body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
+      body('name', 'Group name is required')
+        .trim()
+        .exists({ checkFalsy: true }),
       body('description', 'Description must be a string').optional().isString(),
       body('description', 'Description must be a string').optional().isString(),
       body('parentId', 'ParentId must be a string').optional().isString(),
       body('parentId', 'ParentId must be a string').optional().isString(),
     ],
     ],
     update: [
     update: [
       body('name', 'Group name must be a string').optional().trim().isString(),
       body('name', 'Group name must be a string').optional().trim().isString(),
-      body('description', 'Group description must be a string').optional().isString(),
-      body('parentId', 'ParentId must be a string or null').optional({ nullable: true }).isString(),
-      body('forceUpdateParents', 'forceUpdateParents must be a boolean').optional().isBoolean(),
+      body('description', 'Group description must be a string')
+        .optional()
+        .isString(),
+      body('parentId', 'ParentId must be a string or null')
+        .optional({ nullable: true })
+        .isString(),
+      body('forceUpdateParents', 'forceUpdateParents must be a boolean')
+        .optional()
+        .isBoolean(),
     ],
     ],
     delete: [
     delete: [
       param('id').trim().exists({ checkFalsy: true }),
       param('id').trim().exists({ checkFalsy: true }),
@@ -57,7 +64,9 @@ module.exports = (crowi) => {
     ],
     ],
     listChildren: [
     listChildren: [
       query('parentIds', 'parentIds must be an array').optional().isArray(),
       query('parentIds', 'parentIds must be an array').optional().isArray(),
-      query('includeGrandChildren', 'parentIds must be boolean').optional().isBoolean(),
+      query('includeGrandChildren', 'parentIds must be boolean')
+        .optional()
+        .isBoolean(),
     ],
     ],
     ancestorGroup: [
     ancestorGroup: [
       query('groupId', 'groupId must be a string').optional().isString(),
       query('groupId', 'groupId must be a string').optional().isString(),
@@ -137,27 +146,41 @@ module.exports = (crowi) => {
    *                      type: number
    *                      type: number
    *                      description: the number of items per page
    *                      description: the number of items per page
    */
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, async(req, res) => {
-    const { query } = req;
-
-    try {
-      const page = query.page != null ? parseInt(query.page) : undefined;
-      const limit = query.limit != null ? parseInt(query.limit) : undefined;
-      const offset = query.offset != null ? parseInt(query.offset) : undefined;
-      const pagination = query.pagination != null ? query.pagination !== 'false' : undefined;
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      const { query } = req;
 
 
-      const result = await UserGroup.findWithPagination({
-        page, limit, offset, pagination,
-      });
-      const { docs: userGroups, totalDocs: totalUserGroups, limit: pagingLimit } = result;
-      return res.apiv3({ userGroups, totalUserGroups, pagingLimit });
-    }
-    catch (err) {
-      const msg = 'Error occurred in fetching user group list';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-group-list-fetch-failed'));
-    }
-  });
+      try {
+        const page = query.page != null ? parseInt(query.page) : undefined;
+        const limit = query.limit != null ? parseInt(query.limit) : undefined;
+        const offset =
+          query.offset != null ? parseInt(query.offset) : undefined;
+        const pagination =
+          query.pagination != null ? query.pagination !== 'false' : undefined;
+
+        const result = await UserGroup.findWithPagination({
+          page,
+          limit,
+          offset,
+          pagination,
+        });
+        const {
+          docs: userGroups,
+          totalDocs: totalUserGroups,
+          limit: pagingLimit,
+        } = result;
+        return res.apiv3({ userGroups, totalUserGroups, pagingLimit });
+      } catch (err) {
+        const msg = 'Error occurred in fetching user group list';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'user-group-list-fetch-failed'));
+      }
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -190,23 +213,27 @@ module.exports = (crowi) => {
    *                        type: object
    *                        type: object
    *                      description: userGroup objects
    *                      description: userGroup objects
    */
    */
-  router.get('/ancestors',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+  router.get(
+    '/ancestors',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
     validator.ancestorGroup,
     validator.ancestorGroup,
-    async(req, res) => {
+    async (req, res) => {
       const { groupId } = req.query;
       const { groupId } = req.query;
 
 
       try {
       try {
         const userGroup = await UserGroup.findOne({ _id: { $eq: groupId } });
         const userGroup = await UserGroup.findOne({ _id: { $eq: groupId } });
-        const ancestorUserGroups = await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
+        const ancestorUserGroups =
+          await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
         return res.apiv3({ ancestorUserGroups });
         return res.apiv3({ ancestorUserGroups });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred while searching user groups';
         const msg = 'Error occurred while searching user groups';
         logger.error(msg, err);
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -251,26 +278,33 @@ module.exports = (crowi) => {
    *                          type: object
    *                          type: object
    *                        description: Grandchild user group objects
    *                        description: Grandchild user group objects
    */
    */
-  router.get('/children',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+  router.get(
+    '/children',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
     validator.listChildren,
     validator.listChildren,
-    async(req, res) => {
+    async (req, res) => {
       try {
       try {
         const { parentIds, includeGrandChildren = false } = req.query;
         const { parentIds, includeGrandChildren = false } = req.query;
 
 
-        const userGroupsResult = await UserGroup.findChildrenByParentIds(parentIds, includeGrandChildren);
+        const userGroupsResult = await UserGroup.findChildrenByParentIds(
+          parentIds,
+          includeGrandChildren,
+        );
         return res.apiv3({
         return res.apiv3({
           childUserGroups: userGroupsResult.childUserGroups,
           childUserGroups: userGroupsResult.childUserGroups,
           grandChildUserGroups: userGroupsResult.grandChildUserGroups,
           grandChildUserGroups: userGroupsResult.grandChildUserGroups,
         });
         });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in fetching child user group list';
         const msg = 'Error occurred in fetching child user group list';
         logger.error(msg, err);
         logger.error(msg, err);
-        return res.apiv3Err(new ErrorV3(msg, 'child-user-group-list-fetch-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'child-user-group-list-fetch-failed'),
+        );
       }
       }
-    });
-
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -309,28 +343,39 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: A result of `UserGroup.createGroupByName`
    *                      description: A result of `UserGroup.createGroupByName`
    */
    */
-  router.post('/',
-    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    addActivity, validator.create, apiV3FormValidator,
-    async(req, res) => {
+  router.post(
+    '/',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.create,
+    apiV3FormValidator,
+    async (req, res) => {
       const { name, description = '', parentId } = req.body;
       const { name, description = '', parentId } = req.body;
 
 
       try {
       try {
         const userGroupName = generalXssFilter.process(name);
         const userGroupName = generalXssFilter.process(name);
         const userGroupDescription = generalXssFilter.process(description);
         const userGroupDescription = generalXssFilter.process(description);
-        const userGroup = await UserGroup.createGroup(userGroupName, userGroupDescription, parentId);
-
-        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_CREATE };
+        const userGroup = await UserGroup.createGroup(
+          userGroupName,
+          userGroupDescription,
+          parentId,
+        );
+
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_USER_GROUP_CREATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ userGroup }, 201);
         return res.apiv3({ userGroup }, 201);
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in creating a user group';
         const msg = 'Error occurred in creating a user group';
         logger.error(msg, err);
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-group-create-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'user-group-create-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -363,27 +408,35 @@ module.exports = (crowi) => {
    *                        type: object
    *                        type: object
    *                      description: userGroup objects
    *                      description: userGroup objects
    */
    */
-  router.get('/selectable-parent-groups',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+  router.get(
+    '/selectable-parent-groups',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
     validator.selectableGroups,
     validator.selectableGroups,
-    async(req, res) => {
+    async (req, res) => {
       const { groupId } = req.query;
       const { groupId } = req.query;
 
 
       try {
       try {
         const userGroup = await UserGroup.findOne({ _id: { $eq: groupId } });
         const userGroup = await UserGroup.findOne({ _id: { $eq: groupId } });
 
 
-        const descendantGroups = await UserGroup.findGroupsWithDescendantsRecursively([userGroup], []);
-        const descendantGroupIds = descendantGroups.map(userGroups => userGroups._id.toString());
+        const descendantGroups =
+          await UserGroup.findGroupsWithDescendantsRecursively([userGroup], []);
+        const descendantGroupIds = descendantGroups.map((userGroups) =>
+          userGroups._id.toString(),
+        );
 
 
-        const selectableParentGroups = await UserGroup.find({ _id: { $nin: [groupId, ...descendantGroupIds] } });
+        const selectableParentGroups = await UserGroup.find({
+          _id: { $nin: [groupId, ...descendantGroupIds] },
+        });
         return res.apiv3({ selectableParentGroups });
         return res.apiv3({ selectableParentGroups });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred while searching user groups';
         const msg = 'Error occurred while searching user groups';
         logger.error(msg, err);
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -416,10 +469,13 @@ module.exports = (crowi) => {
    *                        type: object
    *                        type: object
    *                      description: userGroup objects
    *                      description: userGroup objects
    */
    */
-  router.get('/selectable-child-groups',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+  router.get(
+    '/selectable-child-groups',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
     validator.selectableGroups,
     validator.selectableGroups,
-    async(req, res) => {
+    async (req, res) => {
       const { groupId } = req.query;
       const { groupId } = req.query;
 
 
       try {
       try {
@@ -430,16 +486,22 @@ module.exports = (crowi) => {
           UserGroup.findGroupsWithDescendantsRecursively([userGroup], []),
           UserGroup.findGroupsWithDescendantsRecursively([userGroup], []),
         ]);
         ]);
 
 
-        const excludeUserGroupIds = [userGroup, ...ancestorGroups, ...descendantGroups].map(userGroups => userGroups._id.toString());
-        const selectableChildGroups = await UserGroup.find({ _id: { $nin: excludeUserGroupIds } });
+        const excludeUserGroupIds = [
+          userGroup,
+          ...ancestorGroups,
+          ...descendantGroups,
+        ].map((userGroups) => userGroups._id.toString());
+        const selectableChildGroups = await UserGroup.find({
+          _id: { $nin: excludeUserGroupIds },
+        });
         return res.apiv3({ selectableChildGroups });
         return res.apiv3({ selectableChildGroups });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred while searching user groups';
         const msg = 'Error occurred while searching user groups';
         logger.error(msg, err);
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -470,22 +532,25 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: userGroup object
    *                      description: userGroup object
    */
    */
-  router.get('/:id',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+  router.get(
+    '/:id',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
     validator.selectableGroups,
     validator.selectableGroups,
-    async(req, res) => {
+    async (req, res) => {
       const { id: groupId } = req.params;
       const { id: groupId } = req.params;
 
 
       try {
       try {
         const userGroup = await UserGroup.findById(groupId);
         const userGroup = await UserGroup.findById(groupId);
         return res.apiv3({ userGroup });
         return res.apiv3({ userGroup });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred while getting user group';
         const msg = 'Error occurred while getting user group';
         logger.error(msg, err);
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-groups-get-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'user-groups-get-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -531,34 +596,51 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: A result of `UserGroup.removeCompletelyById`
    *                      description: A result of `UserGroup.removeCompletelyById`
    */
    */
-  router.delete('/:id',
-    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    validator.delete, apiV3FormValidator, addActivity,
-    async(req, res) => {
+  router.delete(
+    '/:id',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.delete,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res) => {
       const { id: deleteGroupId } = req.params;
       const { id: deleteGroupId } = req.params;
-      const { actionName, transferToUserGroupId, transferToUserGroupType } = req.query;
-
-      const transferToUserGroup = typeof transferToUserGroupId === 'string'
-        && (transferToUserGroupType === GroupType.userGroup || transferToUserGroupType === GroupType.externalUserGroup)
-        ? {
-          item: transferToUserGroupId,
-          type: transferToUserGroupType,
-        } : undefined;
+      const { actionName, transferToUserGroupId, transferToUserGroupType } =
+        req.query;
+
+      const transferToUserGroup =
+        typeof transferToUserGroupId === 'string' &&
+        (transferToUserGroupType === GroupType.userGroup ||
+          transferToUserGroupType === GroupType.externalUserGroup)
+          ? {
+              item: transferToUserGroupId,
+              type: transferToUserGroupType,
+            }
+          : undefined;
 
 
       try {
       try {
-        const userGroups = await crowi.userGroupService.removeCompletelyByRootGroupId(deleteGroupId, actionName, req.user, transferToUserGroup);
-
-        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_DELETE };
+        const userGroups =
+          await crowi.userGroupService.removeCompletelyByRootGroupId(
+            deleteGroupId,
+            actionName,
+            req.user,
+            transferToUserGroup,
+          );
+
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_USER_GROUP_DELETE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ userGroups });
         return res.apiv3({ userGroups });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred while deleting user groups';
         const msg = 'Error occurred while deleting user groups';
         logger.error(msg, err);
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-groups-delete-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'user-groups-delete-failed'));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -607,30 +689,45 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: A result of `UserGroup.updateName`
    *                      description: A result of `UserGroup.updateName`
    */
    */
-  router.put('/:id',
-    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    validator.update, apiV3FormValidator, addActivity,
-    async(req, res) => {
+  router.put(
+    '/:id',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.update,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res) => {
       const { id } = req.params;
       const { id } = req.params;
       const {
       const {
-        name, description, parentId, forceUpdateParents = false,
+        name,
+        description,
+        parentId,
+        forceUpdateParents = false,
       } = req.body;
       } = req.body;
 
 
       try {
       try {
-        const userGroup = await crowi.userGroupService.updateGroup(id, name, description, parentId, forceUpdateParents);
-
-        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_UPDATE };
+        const userGroup = await crowi.userGroupService.updateGroup(
+          id,
+          name,
+          description,
+          parentId,
+          forceUpdateParents,
+        );
+
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_USER_GROUP_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ userGroup });
         return res.apiv3({ userGroup });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating a user group name';
         const msg = 'Error occurred in updating a user group name';
         logger.error(msg, err);
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-group-update-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'user-group-update-failed'));
       }
       }
-    });
-
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -663,28 +760,34 @@ module.exports = (crowi) => {
    *                        $ref: '#/components/schemas/User'
    *                        $ref: '#/components/schemas/User'
    *                      description: user objects
    *                      description: user objects
    */
    */
-  router.get('/:id/users',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    async(req, res) => {
+  router.get(
+    '/:id/users',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
       const { id } = req.params;
       const { id } = req.params;
 
 
       try {
       try {
         const userGroup = await UserGroup.findById(id);
         const userGroup = await UserGroup.findById(id);
-        const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
+        const userGroupRelations =
+          await UserGroupRelation.findAllRelationForUserGroup(userGroup);
 
 
         const serializeUsers = userGroupRelations.map((userGroupRelation) => {
         const serializeUsers = userGroupRelations.map((userGroupRelation) => {
           return serializeUserSecurely(userGroupRelation.relatedUser);
           return serializeUserSecurely(userGroupRelation.relatedUser);
         });
         });
-        const users = serializeUsers.filter(user => user != null);
+        const users = serializeUsers.filter((user) => user != null);
 
 
         return res.apiv3({ users });
         return res.apiv3({ users });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = `Error occurred in fetching users for group: ${id}`;
         const msg = `Error occurred in fetching users for group: ${id}`;
         logger.error(msg, err);
         logger.error(msg, err);
-        return res.apiv3Err(new ErrorV3(msg, 'user-group-user-list-fetch-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'user-group-user-list-fetch-failed'),
+        );
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -737,21 +840,29 @@ module.exports = (crowi) => {
    *                        $ref: '#/components/schemas/User'
    *                        $ref: '#/components/schemas/User'
    *                      description: user objects
    *                      description: user objects
    */
    */
-  router.get('/:id/unrelated-users',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    async(req, res) => {
+  router.get(
+    '/:id/unrelated-users',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
       const { id } = req.params;
       const { id } = req.params;
-      const {
-        searchWord, searchType, isAlsoNameSearched, isAlsoMailSearched,
-      } = req.query;
+      const { searchWord, searchType, isAlsoNameSearched, isAlsoMailSearched } =
+        req.query;
 
 
       const queryOptions = {
       const queryOptions = {
-        searchWord, searchType, isAlsoNameSearched, isAlsoMailSearched,
+        searchWord,
+        searchType,
+        isAlsoNameSearched,
+        isAlsoMailSearched,
       };
       };
 
 
       try {
       try {
         const userGroup = await UserGroup.findById(id);
         const userGroup = await UserGroup.findById(id);
-        const users = await UserGroupRelation.findUserByNotRelatedGroup(userGroup, queryOptions);
+        const users = await UserGroupRelation.findUserByNotRelatedGroup(
+          userGroup,
+          queryOptions,
+        );
 
 
         // return email only this api
         // return email only this api
         const serializedUsers = users.map((user) => {
         const serializedUsers = users.map((user) => {
@@ -761,14 +872,15 @@ module.exports = (crowi) => {
           return serializedUser;
           return serializedUser;
         });
         });
         return res.apiv3({ users: serializedUsers });
         return res.apiv3({ users: serializedUsers });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = `Error occurred in fetching unrelated users for group: ${id}`;
         const msg = `Error occurred in fetching unrelated users for group: ${id}`;
         logger.error(msg, err);
         logger.error(msg, err);
-        return res.apiv3Err(new ErrorV3(msg, 'user-group-unrelated-user-list-fetch-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'user-group-unrelated-user-list-fetch-failed'),
+        );
       }
       }
-    });
-
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -809,10 +921,15 @@ module.exports = (crowi) => {
    *                      type: number
    *                      type: number
    *                      description: the number of relations created
    *                      description: the number of relations created
    */
    */
-  router.post('/:id/users/:username',
-    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    validator.users.post, apiV3FormValidator, addActivity,
-    async(req, res) => {
+  router.post(
+    '/:id/users/:username',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.users.post,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res) => {
       const { id, username } = req.params;
       const { id, username } = req.params;
 
 
       try {
       try {
@@ -821,28 +938,42 @@ module.exports = (crowi) => {
           User.findUserByUsername(username),
           User.findUserByUsername(username),
         ]);
         ]);
 
 
-        const userGroups = await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
-        const userGroupIds = userGroups.map(g => g._id);
+        const userGroups =
+          await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
+        const userGroupIds = userGroups.map((g) => g._id);
 
 
         // remove existing relations from list to create
         // remove existing relations from list to create
-        const existingRelations = await UserGroupRelation.find({ relatedGroup: { $in: userGroupIds }, relatedUser: user._id });
-        const existingGroupIds = existingRelations.map(r => r.relatedGroup);
-        const groupIdsToCreateRelation = excludeTestIdsFromTargetIds(userGroupIds, existingGroupIds);
-
-        const insertedRelations = await UserGroupRelation.createRelations(groupIdsToCreateRelation, user);
+        const existingRelations = await UserGroupRelation.find({
+          relatedGroup: { $in: userGroupIds },
+          relatedUser: user._id,
+        });
+        const existingGroupIds = existingRelations.map((r) => r.relatedGroup);
+        const groupIdsToCreateRelation = excludeTestIdsFromTargetIds(
+          userGroupIds,
+          existingGroupIds,
+        );
+
+        const insertedRelations = await UserGroupRelation.createRelations(
+          groupIdsToCreateRelation,
+          user,
+        );
         const serializedUser = serializeUserSecurely(user);
         const serializedUser = serializeUserSecurely(user);
 
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_ADD_USER };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_USER_GROUP_ADD_USER,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         activityEvent.emit('update', res.locals.activity._id, parameters);
-        return res.apiv3({ user: serializedUser, createdRelationCount: insertedRelations.length });
-      }
-      catch (err) {
+        return res.apiv3({
+          user: serializedUser,
+          createdRelationCount: insertedRelations.length,
+        });
+      } catch (err) {
         const msg = `Error occurred in adding the user "${username}" to group "${id}"`;
         const msg = `Error occurred in adding the user "${username}" to group "${id}"`;
         logger.error(msg, err);
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-group-add-user-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'user-group-add-user-failed'));
       }
       }
-    });
-
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -884,25 +1015,35 @@ module.exports = (crowi) => {
    *                      type: number
    *                      type: number
    *                      description: the number of groups from which the user was removed
    *                      description: the number of groups from which the user was removed
    */
    */
-  router.delete('/:id/users/:username',
-    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    validator.users.delete, apiV3FormValidator,
-    async(req, res) => {
+  router.delete(
+    '/:id/users/:username',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.users.delete,
+    apiV3FormValidator,
+    async (req, res) => {
       const { id: userGroupId, username } = req.params;
       const { id: userGroupId, username } = req.params;
 
 
       try {
       try {
-        const removedUserRes = await crowi.userGroupService.removeUserByUsername(userGroupId, username);
+        const removedUserRes =
+          await crowi.userGroupService.removeUserByUsername(
+            userGroupId,
+            username,
+          );
         const serializedUser = serializeUserSecurely(removedUserRes.user);
         const serializedUser = serializeUserSecurely(removedUserRes.user);
 
 
-        return res.apiv3({ user: serializedUser, deletedGroupsCount: removedUserRes.deletedGroupsCount });
-      }
-      catch (err) {
+        return res.apiv3({
+          user: serializedUser,
+          deletedGroupsCount: removedUserRes.deletedGroupsCount,
+        });
+      } catch (err) {
         const msg = 'Error occurred while removing the user from groups.';
         const msg = 'Error occurred while removing the user from groups.';
         logger.error(msg, err);
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg, 'user-group-remove-user-failed'));
         return res.apiv3Err(new ErrorV3(msg, 'user-group-remove-user-failed'));
       }
       }
-    });
-
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -935,24 +1076,31 @@ module.exports = (crowi) => {
    *                        type: object
    *                        type: object
    *                      description: userGroupRelation objects
    *                      description: userGroupRelation objects
    */
    */
-  router.get('/:id/user-group-relations',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    async(req, res) => {
+  router.get(
+    '/:id/user-group-relations',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
       const { id } = req.params;
       const { id } = req.params;
 
 
       try {
       try {
         const userGroup = await UserGroup.findById(id);
         const userGroup = await UserGroup.findById(id);
-        const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
-        const serialized = userGroupRelations.map(relation => serializeUserGroupRelationSecurely(relation));
+        const userGroupRelations =
+          await UserGroupRelation.findAllRelationForUserGroup(userGroup);
+        const serialized = userGroupRelations.map((relation) =>
+          serializeUserGroupRelationSecurely(relation),
+        );
         return res.apiv3({ userGroupRelations: serialized });
         return res.apiv3({ userGroupRelations: serialized });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = `Error occurred in fetching user group relations for group: ${id}`;
         const msg = `Error occurred in fetching user group relations for group: ${id}`;
         logger.error(msg, err);
         logger.error(msg, err);
-        return res.apiv3Err(new ErrorV3(msg, 'user-group-user-group-relation-list-fetch-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'user-group-user-group-relation-list-fetch-failed'),
+        );
       }
       }
-    });
-
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -985,26 +1133,33 @@ module.exports = (crowi) => {
    *                        type: object
    *                        type: object
    *                      description: page objects
    *                      description: page objects
    */
    */
-  router.get('/:id/pages',
-    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    validator.pages.get, apiV3FormValidator,
-    async(req, res) => {
+  router.get(
+    '/:id/pages',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.pages.get,
+    apiV3FormValidator,
+    async (req, res) => {
       const { id } = req.params;
       const { id } = req.params;
       const { limit, offset } = req.query;
       const { limit, offset } = req.query;
 
 
       try {
       try {
-        const { docs, totalDocs } = await Page.paginate({
-          grant: Page.GRANT_USER_GROUP,
-          grantedGroups: {
-            $elemMatch: {
-              item: id,
+        const { docs, totalDocs } = await Page.paginate(
+          {
+            grant: Page.GRANT_USER_GROUP,
+            grantedGroups: {
+              $elemMatch: {
+                item: id,
+              },
             },
             },
           },
           },
-        }, {
-          offset,
-          limit,
-          populate: 'lastUpdateUser',
-        });
+          {
+            offset,
+            limit,
+            populate: 'lastUpdateUser',
+          },
+        );
 
 
         const current = offset / limit + 1;
         const current = offset / limit + 1;
 
 
@@ -1015,13 +1170,15 @@ module.exports = (crowi) => {
 
 
         // TODO: create a common moudule for paginated response
         // TODO: create a common moudule for paginated response
         return res.apiv3({ total: totalDocs, current, pages });
         return res.apiv3({ total: totalDocs, current, pages });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = `Error occurred in fetching pages for group: ${id}`;
         const msg = `Error occurred in fetching pages for group: ${id}`;
         logger.error(msg, err);
         logger.error(msg, err);
-        return res.apiv3Err(new ErrorV3(msg, 'user-group-page-list-fetch-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'user-group-page-list-fetch-failed'),
+        );
       }
       }
-    });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

Разница между файлами не показана из-за своего большого размера
+ 402 - 224
apps/app/src/server/routes/apiv3/users.js


+ 17 - 13
apps/app/src/server/service/access-token/access-token-deletion-cron.ts

@@ -7,14 +7,15 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:service:access-token-deletion-cron');
 const logger = loggerFactory('growi:service:access-token-deletion-cron');
 
 
 export class AccessTokenDeletionCronService {
 export class AccessTokenDeletionCronService {
-
   cronJob: nodeCron.ScheduledTask;
   cronJob: nodeCron.ScheduledTask;
 
 
   // Default execution at midnight
   // Default execution at midnight
   accessTokenDeletionCronExpression = '0 15 * * *';
   accessTokenDeletionCronExpression = '0 15 * * *';
 
 
   startCron(): void {
   startCron(): void {
-    const cronExp = configManager.getConfig('accessToken:deletionCronExpression');
+    const cronExp = configManager.getConfig(
+      'accessToken:deletionCronExpression',
+    );
     if (cronExp != null) {
     if (cronExp != null) {
       this.accessTokenDeletionCronExpression = cronExp;
       this.accessTokenDeletionCronExpression = cronExp;
     }
     }
@@ -30,23 +31,26 @@ export class AccessTokenDeletionCronService {
     try {
     try {
       await AccessToken.deleteExpiredToken();
       await AccessToken.deleteExpiredToken();
       logger.info('Expired access tokens have been deleted');
       logger.info('Expired access tokens have been deleted');
-    }
-    catch (e) {
+    } catch (e) {
       logger.error('Failed to delete expired access tokens:', e);
       logger.error('Failed to delete expired access tokens:', e);
     }
     }
   }
   }
 
 
   private generateCronJob() {
   private generateCronJob() {
-    return nodeCron.schedule(this.accessTokenDeletionCronExpression, async() => {
-      try {
-        await this.executeJob();
-      }
-      catch (e) {
-        logger.error('Error occurred during access token deletion cron job:', e);
-      }
-    });
+    return nodeCron.schedule(
+      this.accessTokenDeletionCronExpression,
+      async () => {
+        try {
+          await this.executeJob();
+        } catch (e) {
+          logger.error(
+            'Error occurred during access token deletion cron job:',
+            e,
+          );
+        }
+      },
+    );
   }
   }
-
 }
 }
 
 
 export const startCron = (): void => {
 export const startCron = (): void => {

+ 32 - 36
apps/app/src/server/service/acl.integ.ts

@@ -3,22 +3,24 @@ import type { MockInstance } from 'vitest';
 import { aclService } from './acl';
 import { aclService } from './acl';
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 
 
-
 describe('AclService', () => {
 describe('AclService', () => {
   test("has consts 'isLabeledStatement'", () => {
   test("has consts 'isLabeledStatement'", () => {
     expect(aclService.labels.SECURITY_RESTRICT_GUEST_MODE_DENY).toBe('Deny');
     expect(aclService.labels.SECURITY_RESTRICT_GUEST_MODE_DENY).toBe('Deny');
-    expect(aclService.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY).toBe('Readonly');
+    expect(aclService.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY).toBe(
+      'Readonly',
+    );
     expect(aclService.labels.SECURITY_REGISTRATION_MODE_OPEN).toBe('Open');
     expect(aclService.labels.SECURITY_REGISTRATION_MODE_OPEN).toBe('Open');
-    expect(aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED).toBe('Restricted');
+    expect(aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED).toBe(
+      'Restricted',
+    );
     expect(aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED).toBe('Closed');
     expect(aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED).toBe('Closed');
   });
   });
 });
 });
 
 
 describe('AclService test', () => {
 describe('AclService test', () => {
-
   const initialEnv = process.env;
   const initialEnv = process.env;
 
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     await configManager.loadConfigs();
     await configManager.loadConfigs();
   });
   });
 
 
@@ -27,8 +29,7 @@ describe('AclService test', () => {
   });
   });
 
 
   describe('isAclEnabled()', () => {
   describe('isAclEnabled()', () => {
-
-    test('to be false when FORCE_WIKI_MODE is undefined', async() => {
+    test('to be false when FORCE_WIKI_MODE is undefined', async () => {
       delete process.env.FORCE_WIKI_MODE;
       delete process.env.FORCE_WIKI_MODE;
 
 
       // reload
       // reload
@@ -41,7 +42,7 @@ describe('AclService test', () => {
       expect(result).toBe(true);
       expect(result).toBe(true);
     });
     });
 
 
-    test('to be false when FORCE_WIKI_MODE is dummy string', async() => {
+    test('to be false when FORCE_WIKI_MODE is dummy string', async () => {
       process.env.FORCE_WIKI_MODE = 'dummy string';
       process.env.FORCE_WIKI_MODE = 'dummy string';
 
 
       // reload
       // reload
@@ -54,7 +55,7 @@ describe('AclService test', () => {
       expect(result).toBe(true);
       expect(result).toBe(true);
     });
     });
 
 
-    test('to be true when FORCE_WIKI_MODE=private', async() => {
+    test('to be true when FORCE_WIKI_MODE=private', async () => {
       process.env.FORCE_WIKI_MODE = 'private';
       process.env.FORCE_WIKI_MODE = 'private';
 
 
       // reload
       // reload
@@ -67,7 +68,7 @@ describe('AclService test', () => {
       expect(result).toBe(true);
       expect(result).toBe(true);
     });
     });
 
 
-    test('to be false when FORCE_WIKI_MODE=public', async() => {
+    test('to be false when FORCE_WIKI_MODE=public', async () => {
       process.env.FORCE_WIKI_MODE = 'public';
       process.env.FORCE_WIKI_MODE = 'public';
 
 
       // reload
       // reload
@@ -79,13 +80,10 @@ describe('AclService test', () => {
       expect(wikiMode).toBe('public');
       expect(wikiMode).toBe('public');
       expect(result).toBe(false);
       expect(result).toBe(false);
     });
     });
-
   });
   });
 
 
-
   describe('isWikiModeForced()', () => {
   describe('isWikiModeForced()', () => {
-
-    test('to be false when FORCE_WIKI_MODE is undefined', async() => {
+    test('to be false when FORCE_WIKI_MODE is undefined', async () => {
       delete process.env.FORCE_WIKI_MODE;
       delete process.env.FORCE_WIKI_MODE;
 
 
       // reload
       // reload
@@ -98,7 +96,7 @@ describe('AclService test', () => {
       expect(result).toBe(false);
       expect(result).toBe(false);
     });
     });
 
 
-    test('to be false when FORCE_WIKI_MODE is dummy string', async() => {
+    test('to be false when FORCE_WIKI_MODE is dummy string', async () => {
       process.env.FORCE_WIKI_MODE = 'dummy string';
       process.env.FORCE_WIKI_MODE = 'dummy string';
 
 
       // reload
       // reload
@@ -111,7 +109,7 @@ describe('AclService test', () => {
       expect(result).toBe(false);
       expect(result).toBe(false);
     });
     });
 
 
-    test('to be true when FORCE_WIKI_MODE=private', async() => {
+    test('to be true when FORCE_WIKI_MODE=private', async () => {
       process.env.FORCE_WIKI_MODE = 'private';
       process.env.FORCE_WIKI_MODE = 'private';
 
 
       // reload
       // reload
@@ -124,7 +122,7 @@ describe('AclService test', () => {
       expect(result).toBe(true);
       expect(result).toBe(true);
     });
     });
 
 
-    test('to be false when FORCE_WIKI_MODE=public', async() => {
+    test('to be false when FORCE_WIKI_MODE=public', async () => {
       process.env.FORCE_WIKI_MODE = 'public';
       process.env.FORCE_WIKI_MODE = 'public';
 
 
       // reload
       // reload
@@ -136,19 +134,17 @@ describe('AclService test', () => {
       expect(wikiMode).toBe('public');
       expect(wikiMode).toBe('public');
       expect(result).toBe(true);
       expect(result).toBe(true);
     });
     });
-
   });
   });
 
 
-
   describe('isGuestAllowedToRead()', () => {
   describe('isGuestAllowedToRead()', () => {
     let getConfigSpy: MockInstance<typeof configManager.getConfig>;
     let getConfigSpy: MockInstance<typeof configManager.getConfig>;
 
 
-    beforeEach(async() => {
+    beforeEach(async () => {
       // prepare spy for ConfigManager.getConfig
       // prepare spy for ConfigManager.getConfig
       getConfigSpy = vi.spyOn(configManager, 'getConfig');
       getConfigSpy = vi.spyOn(configManager, 'getConfig');
     });
     });
 
 
-    test('to be false when FORCE_WIKI_MODE=private', async() => {
+    test('to be false when FORCE_WIKI_MODE=private', async () => {
       process.env.FORCE_WIKI_MODE = 'private';
       process.env.FORCE_WIKI_MODE = 'private';
 
 
       // reload
       // reload
@@ -158,11 +154,13 @@ describe('AclService test', () => {
 
 
       const wikiMode = configManager.getConfig('security:wikiMode');
       const wikiMode = configManager.getConfig('security:wikiMode');
       expect(wikiMode).toBe('private');
       expect(wikiMode).toBe('private');
-      expect(getConfigSpy).not.toHaveBeenCalledWith('security:restrictGuestMode');
+      expect(getConfigSpy).not.toHaveBeenCalledWith(
+        'security:restrictGuestMode',
+      );
       expect(result).toBe(false);
       expect(result).toBe(false);
     });
     });
 
 
-    test('to be true when FORCE_WIKI_MODE=public', async() => {
+    test('to be true when FORCE_WIKI_MODE=public', async () => {
       process.env.FORCE_WIKI_MODE = 'public';
       process.env.FORCE_WIKI_MODE = 'public';
 
 
       // reload
       // reload
@@ -172,22 +170,23 @@ describe('AclService test', () => {
 
 
       const wikiMode = configManager.getConfig('security:wikiMode');
       const wikiMode = configManager.getConfig('security:wikiMode');
       expect(wikiMode).toBe('public');
       expect(wikiMode).toBe('public');
-      expect(getConfigSpy).not.toHaveBeenCalledWith('security:restrictGuestMode');
+      expect(getConfigSpy).not.toHaveBeenCalledWith(
+        'security:restrictGuestMode',
+      );
       expect(result).toBe(true);
       expect(result).toBe(true);
     });
     });
 
 
     /* eslint-disable indent */
     /* eslint-disable indent */
     describe.each`
     describe.each`
-      restrictGuestMode   | expected
-      ${undefined}        | ${false}
-      ${'Deny'}           | ${false}
-      ${'Readonly'}       | ${true}
-      ${'Open'}           | ${false}
-      ${'Restricted'}     | ${false}
-      ${'closed'}         | ${false}
+      restrictGuestMode | expected
+      ${undefined}      | ${false}
+      ${'Deny'}         | ${false}
+      ${'Readonly'}     | ${true}
+      ${'Open'}         | ${false}
+      ${'Restricted'}   | ${false}
+      ${'closed'}       | ${false}
     `('to be $expected', ({ restrictGuestMode, expected }) => {
     `('to be $expected', ({ restrictGuestMode, expected }) => {
-      test(`when FORCE_WIKI_MODE is undefined and 'security:restrictGuestMode' is '${restrictGuestMode}`, async() => {
-
+      test(`when FORCE_WIKI_MODE is undefined and 'security:restrictGuestMode' is '${restrictGuestMode}`, async () => {
         // reload
         // reload
         await configManager.loadConfigs();
         await configManager.loadConfigs();
 
 
@@ -210,8 +209,5 @@ describe('AclService test', () => {
         expect(result).toBe(expected);
         expect(result).toBe(expected);
       });
       });
     });
     });
-
   });
   });
-
-
 });
 });

+ 5 - 7
apps/app/src/server/service/acl.ts

@@ -6,18 +6,17 @@ import { configManager } from './config-manager';
 const logger = loggerFactory('growi:service:AclService');
 const logger = loggerFactory('growi:service:AclService');
 
 
 export interface AclService {
 export interface AclService {
-  get labels(): { [key: string]: string },
-  isAclEnabled(): boolean,
-  isWikiModeForced(): boolean,
-  isGuestAllowedToRead(): boolean,
-  getGuestModeValue(): string,
+  get labels(): { [key: string]: string };
+  isAclEnabled(): boolean;
+  isWikiModeForced(): boolean;
+  isGuestAllowedToRead(): boolean;
+  getGuestModeValue(): string;
 }
 }
 
 
 /**
 /**
  * the service class of AclService
  * the service class of AclService
  */
  */
 class AclServiceImpl implements AclService {
 class AclServiceImpl implements AclService {
-
   get labels() {
   get labels() {
     return {
     return {
       SECURITY_RESTRICT_GUEST_MODE_DENY: 'Deny',
       SECURITY_RESTRICT_GUEST_MODE_DENY: 'Deny',
@@ -73,7 +72,6 @@ class AclServiceImpl implements AclService {
       ? this.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY
       ? this.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY
       : this.labels.SECURITY_RESTRICT_GUEST_MODE_DENY;
       : this.labels.SECURITY_RESTRICT_GUEST_MODE_DENY;
   }
   }
-
 }
 }
 
 
 export const aclService = new AclServiceImpl();
 export const aclService = new AclServiceImpl();

+ 99 - 57
apps/app/src/server/service/activity.ts

@@ -3,16 +3,18 @@ import mongoose from 'mongoose';
 
 
 import type { IActivity, SupportedActionType } from '~/interfaces/activity';
 import type { IActivity, SupportedActionType } from '~/interfaces/activity';
 import {
 import {
-  AllSupportedActions, ActionGroupSize,
-  AllEssentialActions, AllSmallGroupActions, AllMediumGroupActions, AllLargeGroupActions,
+  ActionGroupSize,
+  AllEssentialActions,
+  AllLargeGroupActions,
+  AllMediumGroupActions,
+  AllSmallGroupActions,
+  AllSupportedActions,
 } from '~/interfaces/activity';
 } from '~/interfaces/activity';
 import type { ActivityDocument } from '~/server/models/activity';
 import type { ActivityDocument } from '~/server/models/activity';
 import Activity from '~/server/models/activity';
 import Activity from '~/server/models/activity';
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import type Crowi from '../crowi';
 import type Crowi from '../crowi';
-
-
 import type { GeneratePreNotify, GetAdditionalTargetUsers } from './pre-notify';
 import type { GeneratePreNotify, GetAdditionalTargetUsers } from './pre-notify';
 
 
 const logger = loggerFactory('growi:service:ActivityService');
 const logger = loggerFactory('growi:service:ActivityService');
@@ -22,12 +24,13 @@ const parseActionString = (actionsString: string): SupportedActionType[] => {
     return [];
     return [];
   }
   }
 
 
-  const actions = actionsString.split(',').map(value => value.trim());
-  return actions.filter(action => (AllSupportedActions as string[]).includes(action)) as SupportedActionType[];
+  const actions = actionsString.split(',').map((value) => value.trim());
+  return actions.filter((action) =>
+    (AllSupportedActions as string[]).includes(action),
+  ) as SupportedActionType[];
 };
 };
 
 
 class ActivityService {
 class ActivityService {
-
   crowi!: Crowi;
   crowi!: Crowi;
 
 
   activityEvent: any;
   activityEvent: any;
@@ -43,40 +46,60 @@ class ActivityService {
   }
   }
 
 
   initActivityEventListeners(): void {
   initActivityEventListeners(): void {
-    this.activityEvent.on('update', async(
-        activityId: string, parameters, target: IPage, generatePreNotify?: GeneratePreNotify, getAdditionalTargetUsers?: GetAdditionalTargetUsers,
-    ) => {
-      let activity: ActivityDocument;
-      const shoudUpdate = this.shoudUpdateActivity(parameters.action);
-
-      if (shoudUpdate) {
-        try {
-          activity = await Activity.updateByParameters(activityId, parameters);
+    this.activityEvent.on(
+      'update',
+      async (
+        activityId: string,
+        parameters,
+        target: IPage,
+        generatePreNotify?: GeneratePreNotify,
+        getAdditionalTargetUsers?: GetAdditionalTargetUsers,
+      ) => {
+        let activity: ActivityDocument;
+        const shoudUpdate = this.shoudUpdateActivity(parameters.action);
+
+        if (shoudUpdate) {
+          try {
+            activity = await Activity.updateByParameters(
+              activityId,
+              parameters,
+            );
+          } catch (err) {
+            logger.error('Update activity failed', err);
+            return;
+          }
+
+          if (generatePreNotify != null) {
+            const preNotify = generatePreNotify(
+              activity,
+              getAdditionalTargetUsers,
+            );
+
+            this.activityEvent.emit('updated', activity, target, preNotify);
+
+            return;
+          }
+
+          this.activityEvent.emit('updated', activity, target);
         }
         }
-        catch (err) {
-          logger.error('Update activity failed', err);
-          return;
-        }
-
-        if (generatePreNotify != null) {
-          const preNotify = generatePreNotify(activity, getAdditionalTargetUsers);
-
-          this.activityEvent.emit('updated', activity, target, preNotify);
-
-          return;
-        }
-
-        this.activityEvent.emit('updated', activity, target);
-
-      }
-    });
+      },
+    );
   }
   }
 
 
-  getAvailableActions = function(isIncludeEssentialActions = true): SupportedActionType[] {
-    const auditLogEnabled = this.crowi.configManager.getConfig('app:auditLogEnabled') || false;
-    const auditLogActionGroupSize = this.crowi.configManager.getConfig('app:auditLogActionGroupSize') || ActionGroupSize.Small;
-    const auditLogAdditionalActions = this.crowi.configManager.getConfig('app:auditLogAdditionalActions');
-    const auditLogExcludeActions = this.crowi.configManager.getConfig('app:auditLogExcludeActions');
+  getAvailableActions = function (
+    isIncludeEssentialActions = true,
+  ): SupportedActionType[] {
+    const auditLogEnabled =
+      this.crowi.configManager.getConfig('app:auditLogEnabled') || false;
+    const auditLogActionGroupSize =
+      this.crowi.configManager.getConfig('app:auditLogActionGroupSize') ||
+      ActionGroupSize.Small;
+    const auditLogAdditionalActions = this.crowi.configManager.getConfig(
+      'app:auditLogAdditionalActions',
+    );
+    const auditLogExcludeActions = this.crowi.configManager.getConfig(
+      'app:auditLogExcludeActions',
+    );
 
 
     if (!auditLogEnabled) {
     if (!auditLogEnabled) {
       return AllEssentialActions;
       return AllEssentialActions;
@@ -87,55 +110,71 @@ class ActivityService {
     // Set base action group
     // Set base action group
     switch (auditLogActionGroupSize) {
     switch (auditLogActionGroupSize) {
       case ActionGroupSize.Small:
       case ActionGroupSize.Small:
-        AllSmallGroupActions.forEach(action => availableActionsSet.add(action));
+        AllSmallGroupActions.forEach((action) => {
+          availableActionsSet.add(action);
+        });
         break;
         break;
       case ActionGroupSize.Medium:
       case ActionGroupSize.Medium:
-        AllMediumGroupActions.forEach(action => availableActionsSet.add(action));
+        AllMediumGroupActions.forEach((action) => {
+          availableActionsSet.add(action);
+        });
         break;
         break;
       case ActionGroupSize.Large:
       case ActionGroupSize.Large:
-        AllLargeGroupActions.forEach(action => availableActionsSet.add(action));
+        AllLargeGroupActions.forEach((action) => {
+          availableActionsSet.add(action);
+        });
         break;
         break;
     }
     }
 
 
     // Add additionalActions
     // Add additionalActions
     const additionalActions = parseActionString(auditLogAdditionalActions);
     const additionalActions = parseActionString(auditLogAdditionalActions);
-    additionalActions.forEach(action => availableActionsSet.add(action));
+    additionalActions.forEach((action) => {
+      availableActionsSet.add(action);
+    });
 
 
     // Delete excludeActions
     // Delete excludeActions
     const excludeActions = parseActionString(auditLogExcludeActions);
     const excludeActions = parseActionString(auditLogExcludeActions);
-    excludeActions.forEach(action => availableActionsSet.delete(action));
+    excludeActions.forEach((action) => {
+      availableActionsSet.delete(action);
+    });
 
 
     // Add essentialActions
     // Add essentialActions
     if (isIncludeEssentialActions) {
     if (isIncludeEssentialActions) {
-      AllEssentialActions.forEach(action => availableActionsSet.add(action));
+      AllEssentialActions.forEach((action) => {
+        availableActionsSet.add(action);
+      });
     }
     }
 
 
     return Array.from(availableActionsSet);
     return Array.from(availableActionsSet);
   };
   };
 
 
-  shoudUpdateActivity = function(action: SupportedActionType): boolean {
+  shoudUpdateActivity = function (action: SupportedActionType): boolean {
     return this.getAvailableActions().includes(action);
     return this.getAvailableActions().includes(action);
   };
   };
 
 
   // for GET request
   // for GET request
-  createActivity = async function(parameters): Promise<IActivity | null> {
-    const shoudCreateActivity = this.crowi.activityService.shoudUpdateActivity(parameters.action);
+  createActivity = async function (parameters): Promise<IActivity | null> {
+    const shoudCreateActivity = this.crowi.activityService.shoudUpdateActivity(
+      parameters.action,
+    );
     if (shoudCreateActivity) {
     if (shoudCreateActivity) {
       let activity: IActivity;
       let activity: IActivity;
       try {
       try {
         activity = await Activity.createByParameters(parameters);
         activity = await Activity.createByParameters(parameters);
         return activity;
         return activity;
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Create activity failed', err);
         logger.error('Create activity failed', err);
       }
       }
     }
     }
     return null;
     return null;
   };
   };
 
 
-  createTtlIndex = async function() {
+  createTtlIndex = async function () {
     const configManager = this.crowi.configManager;
     const configManager = this.crowi.configManager;
-    const activityExpirationSeconds = configManager != null ? configManager.getConfig('app:activityExpirationSeconds') : 2592000;
+    const activityExpirationSeconds =
+      configManager != null
+        ? configManager.getConfig('app:activityExpirationSeconds')
+        : 2592000;
 
 
     try {
     try {
       // create the collection with indexes at first
       // create the collection with indexes at first
@@ -145,9 +184,11 @@ class ActivityService {
       const indexes = await collection.indexes();
       const indexes = await collection.indexes();
 
 
       const targetField = 'createdAt_1';
       const targetField = 'createdAt_1';
-      const foundCreatedAt = indexes.find(i => i.name === targetField);
+      const foundCreatedAt = indexes.find((i) => i.name === targetField);
 
 
-      const isNotSpec = foundCreatedAt?.expireAfterSeconds == null || foundCreatedAt?.expireAfterSeconds !== activityExpirationSeconds;
+      const isNotSpec =
+        foundCreatedAt?.expireAfterSeconds == null ||
+        foundCreatedAt?.expireAfterSeconds !== activityExpirationSeconds;
       const shoudDropIndex = foundCreatedAt != null && isNotSpec;
       const shoudDropIndex = foundCreatedAt != null && isNotSpec;
       const shoudCreateIndex = foundCreatedAt == null || shoudDropIndex;
       const shoudCreateIndex = foundCreatedAt == null || shoudDropIndex;
 
 
@@ -156,15 +197,16 @@ class ActivityService {
       }
       }
 
 
       if (shoudCreateIndex) {
       if (shoudCreateIndex) {
-        await collection.createIndex({ createdAt: 1 }, { expireAfterSeconds: activityExpirationSeconds });
+        await collection.createIndex(
+          { createdAt: 1 },
+          { expireAfterSeconds: activityExpirationSeconds },
+        );
       }
       }
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Failed to create TTL Index', err);
       logger.error('Failed to create TTL Index', err);
       throw err;
       throw err;
     }
     }
   };
   };
-
 }
 }
 
 
 module.exports = ActivityService;
 module.exports = ActivityService;

+ 5 - 7
apps/app/src/server/service/app.ts

@@ -4,7 +4,6 @@ import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../crowi';
 import type Crowi from '../crowi';
 import S2sMessage from '../models/vo/s2s-message';
 import S2sMessage from '../models/vo/s2s-message';
-
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 import type { S2sMessagingService } from './s2s-messaging/base';
 import type { S2sMessagingService } from './s2s-messaging/base';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
@@ -14,7 +13,6 @@ const logger = loggerFactory('growi:service:AppService');
  * the service class of AppService
  * the service class of AppService
  */
  */
 export default class AppService implements S2sMessageHandlable {
 export default class AppService implements S2sMessageHandlable {
-
   crowi: Crowi;
   crowi: Crowi;
 
 
   s2sMessagingService: S2sMessagingService;
   s2sMessagingService: S2sMessagingService;
@@ -64,12 +62,13 @@ export default class AppService implements S2sMessageHandlable {
 
 
       try {
       try {
         await s2sMessagingService.publish(s2sMessage);
         await s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish post installation message with S2sMessagingService: ', e.message);
+      } catch (e) {
+        logger.error(
+          'Failed to publish post installation message with S2sMessagingService: ',
+          e.message,
+        );
       }
       }
     }
     }
-
   }
   }
 
 
   getAppTitle() {
   getAppTitle() {
@@ -108,5 +107,4 @@ export default class AppService implements S2sMessageHandlable {
   async endMaintenanceMode(): Promise<void> {
   async endMaintenanceMode(): Promise<void> {
     await configManager.updateConfig('app:isMaintenanceMode', false);
     await configManager.updateConfig('app:isMaintenanceMode', false);
   }
   }
-
 }
 }

+ 51 - 25
apps/app/src/server/service/attachment.ts

@@ -1,4 +1,5 @@
 import type { IAttachment, Ref } from '@growi/core/dist/interfaces';
 import type { IAttachment, Ref } from '@growi/core/dist/interfaces';
+import type { ReadStream } from 'fs';
 import type { HydratedDocument } from 'mongoose';
 import type { HydratedDocument } from 'mongoose';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -15,20 +16,30 @@ const mongoose = require('mongoose');
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:service:AttachmentService');
 const logger = loggerFactory('growi:service:AttachmentService');
 
 
-const createReadStream = (filePath) => {
+const createReadStream = (filePath: string): ReadStream => {
   return fs.createReadStream(filePath, {
   return fs.createReadStream(filePath, {
-    flags: 'r', encoding: null, fd: null, mode: '0666', autoClose: true,
+    flags: 'r',
+    encoding: null,
+    fd: null,
+    mode: '0666',
+    autoClose: true,
   });
   });
 };
 };
 
 
-type AttachHandler = (pageId: string | null, attachment: IAttachmentDocument, file: Express.Multer.File) => Promise<void>;
+type AttachHandler = (
+  pageId: string | null,
+  attachment: IAttachmentDocument,
+  file: Express.Multer.File,
+) => Promise<void>;
 
 
 type DetachHandler = (attachmentId: string) => Promise<void>;
 type DetachHandler = (attachmentId: string) => Promise<void>;
 
 
-
 type IAttachmentService = {
 type IAttachmentService = {
   createAttachment(
   createAttachment(
-    file: Express.Multer.File, user: any, pageId: string | null, attachmentType: AttachmentType,
+    file: Express.Multer.File,
+    user: any,
+    pageId: string | null,
+    attachmentType: AttachmentType,
     disposeTmpFileCallback?: (file: Express.Multer.File) => void,
     disposeTmpFileCallback?: (file: Express.Multer.File) => void,
   ): Promise<IAttachmentDocument>;
   ): Promise<IAttachmentDocument>;
   removeAllAttachments(attachments: IAttachmentDocument[]): Promise<void>;
   removeAllAttachments(attachments: IAttachmentDocument[]): Promise<void>;
@@ -38,12 +49,10 @@ type IAttachmentService = {
   addDetachHandler(handler: DetachHandler): void;
   addDetachHandler(handler: DetachHandler): void;
 };
 };
 
 
-
 /**
 /**
  * the service class for Attachment and file-uploader
  * the service class for Attachment and file-uploader
  */
  */
 export class AttachmentService implements IAttachmentService {
 export class AttachmentService implements IAttachmentService {
-
   attachHandlers: AttachHandler[] = [];
   attachHandlers: AttachHandler[] = [];
 
 
   detachHandlers: DetachHandler[] = [];
   detachHandlers: DetachHandler[] = [];
@@ -54,7 +63,13 @@ export class AttachmentService implements IAttachmentService {
     this.crowi = crowi;
     this.crowi = crowi;
   }
   }
 
 
-  async createAttachment(file, user, pageId: string | null | undefined = null, attachmentType, disposeTmpFileCallback): Promise<IAttachmentDocument> {
+  async createAttachment(
+    file,
+    user,
+    pageId: string | null | undefined = null,
+    attachmentType,
+    disposeTmpFileCallback,
+  ): Promise<IAttachmentDocument> {
     const { fileUploadService } = this.crowi;
     const { fileUploadService } = this.crowi;
 
 
     // check limit
     // check limit
@@ -64,12 +79,22 @@ export class AttachmentService implements IAttachmentService {
     }
     }
 
 
     // create an Attachment document and upload file
     // create an Attachment document and upload file
-    let attachment;
-    let readStreamForCreateAttachmentDocument;
+    let attachment: IAttachmentDocument;
+    let readStreamForCreateAttachmentDocument: ReadStream | null = null;
     try {
     try {
       readStreamForCreateAttachmentDocument = createReadStream(file.path);
       readStreamForCreateAttachmentDocument = createReadStream(file.path);
-      attachment = Attachment.createWithoutSave(pageId, user, file.originalname, file.mimetype, file.size, attachmentType);
-      await fileUploadService.uploadAttachment(readStreamForCreateAttachmentDocument, attachment);
+      attachment = Attachment.createWithoutSave(
+        pageId,
+        user,
+        file.originalname,
+        file.mimetype,
+        file.size,
+        attachmentType,
+      );
+      await fileUploadService.uploadAttachment(
+        readStreamForCreateAttachmentDocument,
+        attachment,
+      );
       await attachment.save();
       await attachment.save();
 
 
       const attachHandlerPromises = this.attachHandlers.map((handler) => {
       const attachHandlerPromises = this.attachHandlers.map((handler) => {
@@ -84,23 +109,24 @@ export class AttachmentService implements IAttachmentService {
         .finally(() => {
         .finally(() => {
           disposeTmpFileCallback?.(file);
           disposeTmpFileCallback?.(file);
         });
         });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Error while creating attachment', err);
       logger.error('Error while creating attachment', err);
       disposeTmpFileCallback?.(file);
       disposeTmpFileCallback?.(file);
       throw err;
       throw err;
-    }
-    finally {
-      readStreamForCreateAttachmentDocument.destroy();
+    } finally {
+      readStreamForCreateAttachmentDocument?.destroy();
     }
     }
 
 
     return attachment;
     return attachment;
   }
   }
 
 
-  async removeAllAttachments(attachments: HydratedDocument<IAttachmentDocument>[]): Promise<void> {
+  async removeAllAttachments(
+    attachments: HydratedDocument<IAttachmentDocument>[],
+  ): Promise<void> {
     const { fileUploadService } = this.crowi;
     const { fileUploadService } = this.crowi;
     const attachmentsCollection = mongoose.connection.collection('attachments');
     const attachmentsCollection = mongoose.connection.collection('attachments');
-    const unorderAttachmentsBulkOp = attachmentsCollection.initializeUnorderedBulkOp();
+    const unorderAttachmentsBulkOp =
+      attachmentsCollection.initializeUnorderedBulkOp();
 
 
     if (attachments.length === 0) {
     if (attachments.length === 0) {
       return;
       return;
@@ -116,7 +142,9 @@ export class AttachmentService implements IAttachmentService {
     return;
     return;
   }
   }
 
 
-  async removeAttachment(attachmentId: Ref<IAttachment> | undefined): Promise<void> {
+  async removeAttachment(
+    attachmentId: Ref<IAttachment> | undefined,
+  ): Promise<void> {
     const { fileUploadService } = this.crowi;
     const { fileUploadService } = this.crowi;
     const attachment = await Attachment.findById(attachmentId);
     const attachment = await Attachment.findById(attachmentId);
 
 
@@ -132,10 +160,9 @@ export class AttachmentService implements IAttachmentService {
     });
     });
 
 
     // Do not await, run in background
     // Do not await, run in background
-    Promise.all(detachedHandlerPromises)
-      .catch((err) => {
-        logger.error('Error while executing detached handler', err);
-      });
+    Promise.all(detachedHandlerPromises).catch((err) => {
+      logger.error('Error while executing detached handler', err);
+    });
 
 
     return;
     return;
   }
   }
@@ -162,5 +189,4 @@ export class AttachmentService implements IAttachmentService {
   addDetachHandler(handler: DetachHandler): void {
   addDetachHandler(handler: DetachHandler): void {
     this.detachHandlers.push(handler);
     this.detachHandlers.push(handler);
   }
   }
-
 }
 }

+ 22 - 19
apps/app/src/server/service/comment.ts

@@ -13,7 +13,6 @@ const USERNAME_PATTERN = new RegExp(/\B@[\w@.-]+/g);
 const logger = loggerFactory('growi:service:CommentService');
 const logger = loggerFactory('growi:service:CommentService');
 
 
 class CommentService {
 class CommentService {
-
   crowi!: Crowi;
   crowi!: Crowi;
 
 
   activityService!: any;
   activityService!: any;
@@ -31,35 +30,35 @@ class CommentService {
 
 
   initCommentEventListeners(): void {
   initCommentEventListeners(): void {
     // create
     // create
-    commentEvent.on(CommentEvent.CREATE, async(savedComment) => {
-
+    commentEvent.on(CommentEvent.CREATE, async (savedComment) => {
       try {
       try {
         const Page = pageModelFactory(this.crowi);
         const Page = pageModelFactory(this.crowi);
         await Page.updateCommentCount(savedComment.page);
         await Page.updateCommentCount(savedComment.page);
+      } catch (err) {
+        logger.error(
+          'Error occurred while handling the comment create event:\n',
+          err,
+        );
       }
       }
-      catch (err) {
-        logger.error('Error occurred while handling the comment create event:\n', err);
-      }
-
     });
     });
 
 
     // update
     // update
-    commentEvent.on(CommentEvent.UPDATE, async() => {
-    });
+    commentEvent.on(CommentEvent.UPDATE, async () => {});
 
 
     // remove
     // remove
-    commentEvent.on(CommentEvent.DELETE, async(removedComment) => {
+    commentEvent.on(CommentEvent.DELETE, async (removedComment) => {
       try {
       try {
         const Page = pageModelFactory(this.crowi);
         const Page = pageModelFactory(this.crowi);
         await Page.updateCommentCount(removedComment.page);
         await Page.updateCommentCount(removedComment.page);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Error occurred while updating the comment count:\n', err);
         logger.error('Error occurred while updating the comment count:\n', err);
       }
       }
     });
     });
   }
   }
 
 
-  getMentionedUsers = async(commentId: Types.ObjectId): Promise<Types.ObjectId[]> => {
+  getMentionedUsers = async (
+    commentId: Types.ObjectId,
+  ): Promise<Types.ObjectId[]> => {
     const User = userModelFactory(this.crowi);
     const User = userModelFactory(this.crowi);
 
 
     // Get comment by comment ID
     // Get comment by comment ID
@@ -76,18 +75,22 @@ class CommentService {
     const usernamesFromComment = comment.match(USERNAME_PATTERN);
     const usernamesFromComment = comment.match(USERNAME_PATTERN);
 
 
     // Get username from comment and remove duplicate username
     // Get username from comment and remove duplicate username
-    const mentionedUsernames = [...new Set(usernamesFromComment?.map((username) => {
-      return username.slice(1);
-    }))];
+    const mentionedUsernames = [
+      ...new Set(
+        usernamesFromComment?.map((username) => {
+          return username.slice(1);
+        }),
+      ),
+    ];
 
 
     // Get mentioned users ID
     // Get mentioned users ID
-    const mentionedUserIDs = await User.find({ username: { $in: mentionedUsernames } });
+    const mentionedUserIDs = await User.find({
+      username: { $in: mentionedUsernames },
+    });
     return mentionedUserIDs?.map((user) => {
     return mentionedUserIDs?.map((user) => {
       return user._id;
       return user._id;
     });
     });
   };
   };
-
 }
 }
 
 
-
 module.exports = CommentService;
 module.exports = CommentService;

+ 154 - 92
apps/app/src/server/service/config-manager/config-definition.ts

@@ -1,14 +1,18 @@
 import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
 import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
-import type { ConfigDefinition, Lang, NonBlankString } from '@growi/core/dist/interfaces';
-import {
-  toNonBlankString,
-  defineConfig,
+import type {
+  ConfigDefinition,
+  Lang,
+  NonBlankString,
 } from '@growi/core/dist/interfaces';
 } from '@growi/core/dist/interfaces';
+import { defineConfig, toNonBlankString } from '@growi/core/dist/interfaces';
 import type OpenAI from 'openai';
 import type OpenAI from 'openai';
 
 
 import { ActionGroupSize } from '~/interfaces/activity';
 import { ActionGroupSize } from '~/interfaces/activity';
 import { AttachmentMethodType } from '~/interfaces/attachment';
 import { AttachmentMethodType } from '~/interfaces/attachment';
-import type { IPageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation } from '~/interfaces/page-delete-config';
+import type {
+  IPageDeleteConfigValue,
+  IPageDeleteConfigValueToProcessValidation,
+} from '~/interfaces/page-delete-config';
 import type { RegistrationMode } from '~/interfaces/registration-mode';
 import type { RegistrationMode } from '~/interfaces/registration-mode';
 import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
 import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
 
 
@@ -331,10 +335,8 @@ export const CONFIG_KEYS = [
   'accessToken:deletionCronExpression',
   'accessToken:deletionCronExpression',
 ] as const;
 ] as const;
 
 
-
 export type ConfigKey = (typeof CONFIG_KEYS)[number];
 export type ConfigKey = (typeof CONFIG_KEYS)[number];
 
 
-
 export const CONFIG_DEFINITIONS = {
 export const CONFIG_DEFINITIONS = {
   // Auto Install Settings
   // Auto Install Settings
   'autoInstall:adminUsername': defineConfig<string | undefined>({
   'autoInstall:adminUsername': defineConfig<string | undefined>({
@@ -438,7 +440,7 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'FILE_UPLOAD_TOTAL_LIMIT',
     envVarName: 'FILE_UPLOAD_TOTAL_LIMIT',
     defaultValue: Infinity,
     defaultValue: Infinity,
   }),
   }),
-  'app:elasticsearchVersion': defineConfig<7|8|9>({
+  'app:elasticsearchVersion': defineConfig<7 | 8 | 9>({
     envVarName: 'ELASTICSEARCH_VERSION',
     envVarName: 'ELASTICSEARCH_VERSION',
     defaultValue: 9,
     defaultValue: 9,
   }),
   }),
@@ -522,10 +524,12 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'OPENAI_THREAD_DELETION_CRON_MAX_MINUTES_UNTIL_REQUEST',
     envVarName: 'OPENAI_THREAD_DELETION_CRON_MAX_MINUTES_UNTIL_REQUEST',
     defaultValue: 30,
     defaultValue: 30,
   }),
   }),
-  'app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest': defineConfig<number>({
-    envVarName: 'OPENAI_VECTOR_STORE_FILE_DELETION_CRON_MAX_MINUTES_UNTIL_REQUEST',
-    defaultValue: 30,
-  }),
+  'app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest':
+    defineConfig<number>({
+      envVarName:
+        'OPENAI_VECTOR_STORE_FILE_DELETION_CRON_MAX_MINUTES_UNTIL_REQUEST',
+      defaultValue: 30,
+    }),
 
 
   // Security Settings
   // Security Settings
   'security:wikiMode': defineConfig<string | undefined>({
   'security:wikiMode': defineConfig<string | undefined>({
@@ -564,10 +568,12 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'LOCAL_STRATEGY_PASSWORD_RESET_ENABLED',
     envVarName: 'LOCAL_STRATEGY_PASSWORD_RESET_ENABLED',
     defaultValue: true,
     defaultValue: true,
   }),
   }),
-  'security:passport-local:isEmailAuthenticationEnabled': defineConfig<boolean>({
-    envVarName: 'LOCAL_STRATEGY_EMAIL_AUTHENTICATION_ENABLED',
-    defaultValue: false,
-  }),
+  'security:passport-local:isEmailAuthenticationEnabled': defineConfig<boolean>(
+    {
+      envVarName: 'LOCAL_STRATEGY_EMAIL_AUTHENTICATION_ENABLED',
+      defaultValue: false,
+    },
+  ),
   'security:passport-saml:isEnabled': defineConfig<boolean>({
   'security:passport-saml:isEnabled': defineConfig<boolean>({
     envVarName: 'SAML_ENABLED',
     envVarName: 'SAML_ENABLED',
     defaultValue: false,
     defaultValue: false,
@@ -646,27 +652,37 @@ export const CONFIG_DEFINITIONS = {
   'security:list-policy:hideRestrictedByGroup': defineConfig<boolean>({
   'security:list-policy:hideRestrictedByGroup': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),
-  'security:pageDeletionAuthority': defineConfig<IPageDeleteConfigValueToProcessValidation | undefined>({
+  'security:pageDeletionAuthority': defineConfig<
+    IPageDeleteConfigValueToProcessValidation | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:pageCompleteDeletionAuthority': defineConfig<IPageDeleteConfigValueToProcessValidation | undefined>({
+  'security:pageCompleteDeletionAuthority': defineConfig<
+    IPageDeleteConfigValueToProcessValidation | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:pageRecursiveDeletionAuthority': defineConfig<IPageDeleteConfigValue | undefined>({
+  'security:pageRecursiveDeletionAuthority': defineConfig<
+    IPageDeleteConfigValue | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:pageRecursiveCompleteDeletionAuthority': defineConfig<IPageDeleteConfigValue | undefined>({
+  'security:pageRecursiveCompleteDeletionAuthority': defineConfig<
+    IPageDeleteConfigValue | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:isAllGroupMembershipRequiredForPageCompleteDeletion': defineConfig<boolean>({
-    defaultValue: true,
-  }),
+  'security:isAllGroupMembershipRequiredForPageCompleteDeletion':
+    defineConfig<boolean>({
+      defaultValue: true,
+    }),
   'security:user-homepage-deletion:isEnabled': defineConfig<boolean>({
   'security:user-homepage-deletion:isEnabled': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),
-  'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': defineConfig<boolean>({
-    defaultValue: false,
-  }),
+  'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
   'security:isRomUserAllowedToComment': defineConfig<boolean>({
   'security:isRomUserAllowedToComment': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),
@@ -706,30 +722,39 @@ export const CONFIG_DEFINITIONS = {
   'security:passport-ldap:groupDnProperty': defineConfig<string | undefined>({
   'security:passport-ldap:groupDnProperty': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
-  'security:passport-saml:isSameEmailTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
-  'security:passport-saml:isSameUsernameTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
+  'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
+  'security:passport-saml:isSameEmailTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
+  'security:passport-saml:isSameUsernameTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
   'security:passport-google:isEnabled': defineConfig<boolean>({
   'security:passport-google:isEnabled': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),
-  'security:passport-google:clientId': defineConfig<NonBlankString | undefined>({
-    defaultValue: undefined,
-  }),
-  'security:passport-google:clientSecret': defineConfig<NonBlankString | undefined>({
-    defaultValue: undefined,
-  }),
-  'security:passport-google:isSameUsernameTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
-  'security:passport-google:isSameEmailTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
+  'security:passport-google:clientId': defineConfig<NonBlankString | undefined>(
+    {
+      defaultValue: undefined,
+    },
+  ),
+  'security:passport-google:clientSecret': defineConfig<
+    NonBlankString | undefined
+  >({
+    defaultValue: undefined,
+  }),
+  'security:passport-google:isSameUsernameTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
+  'security:passport-google:isSameEmailTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
   'security:passport-github:isEnabled': defineConfig<boolean>({
   'security:passport-github:isEnabled': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),
@@ -739,12 +764,14 @@ export const CONFIG_DEFINITIONS = {
   'security:passport-github:clientSecret': defineConfig<string | undefined>({
   'security:passport-github:clientSecret': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:passport-github:isSameUsernameTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
-  'security:passport-github:isSameEmailTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
+  'security:passport-github:isSameUsernameTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
+  'security:passport-github:isSameEmailTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
   'security:passport-oidc:clientId': defineConfig<string | undefined>({
   'security:passport-oidc:clientId': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
@@ -757,36 +784,48 @@ export const CONFIG_DEFINITIONS = {
   'security:passport-oidc:issuerHost': defineConfig<string | undefined>({
   'security:passport-oidc:issuerHost': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:passport-oidc:authorizationEndpoint': defineConfig<string | undefined>({
+  'security:passport-oidc:authorizationEndpoint': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
   'security:passport-oidc:tokenEndpoint': defineConfig<string | undefined>({
   'security:passport-oidc:tokenEndpoint': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:passport-oidc:revocationEndpoint': defineConfig<string | undefined>({
-    defaultValue: undefined,
-  }),
-  'security:passport-oidc:introspectionEndpoint': defineConfig<string | undefined>({
+  'security:passport-oidc:revocationEndpoint': defineConfig<string | undefined>(
+    {
+      defaultValue: undefined,
+    },
+  ),
+  'security:passport-oidc:introspectionEndpoint': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
   'security:passport-oidc:userInfoEndpoint': defineConfig<string | undefined>({
   'security:passport-oidc:userInfoEndpoint': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:passport-oidc:endSessionEndpoint': defineConfig<string | undefined>({
-    defaultValue: undefined,
-  }),
-  'security:passport-oidc:registrationEndpoint': defineConfig<string | undefined>({
+  'security:passport-oidc:endSessionEndpoint': defineConfig<string | undefined>(
+    {
+      defaultValue: undefined,
+    },
+  ),
+  'security:passport-oidc:registrationEndpoint': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
   'security:passport-oidc:jwksUri': defineConfig<string | undefined>({
   'security:passport-oidc:jwksUri': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
-  'security:passport-oidc:isSameEmailTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
+  'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
+  'security:passport-oidc:isSameEmailTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
 
 
   // File Upload Settings
   // File Upload Settings
   'fileUpload:local:useInternalRedirect': defineConfig<boolean>({
   'fileUpload:local:useInternalRedirect': defineConfig<boolean>({
@@ -1051,7 +1090,9 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'SLACKBOT_WITHOUT_PROXY_COMMAND_PERMISSION',
     envVarName: 'SLACKBOT_WITHOUT_PROXY_COMMAND_PERMISSION',
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'slackbot:withoutProxy:eventActionsPermission': defineConfig<string | undefined>({
+  'slackbot:withoutProxy:eventActionsPermission': defineConfig<
+    string | undefined
+  >({
     envVarName: 'SLACKBOT_WITHOUT_PROXY_EVENT_ACTIONS_PERMISSION',
     envVarName: 'SLACKBOT_WITHOUT_PROXY_EVENT_ACTIONS_PERMISSION',
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
@@ -1186,28 +1227,40 @@ export const CONFIG_DEFINITIONS = {
   }),
   }),
 
 
   // External User Group Settings
   // External User Group Settings
-  'external-user-group:ldap:groupMembershipAttributeType': defineConfig<string>({
-    defaultValue: 'DN',
-  }),
+  'external-user-group:ldap:groupMembershipAttributeType': defineConfig<string>(
+    {
+      defaultValue: 'DN',
+    },
+  ),
   'external-user-group:ldap:groupSearchBase': defineConfig<string | undefined>({
   'external-user-group:ldap:groupSearchBase': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'external-user-group:ldap:groupMembershipAttribute': defineConfig<string | undefined>({
+  'external-user-group:ldap:groupMembershipAttribute': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'external-user-group:ldap:groupChildGroupAttribute': defineConfig<string | undefined>({
+  'external-user-group:ldap:groupChildGroupAttribute': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'external-user-group:ldap:autoGenerateUserOnGroupSync': defineConfig<boolean>({
-    defaultValue: false,
-  }),
+  'external-user-group:ldap:autoGenerateUserOnGroupSync': defineConfig<boolean>(
+    {
+      defaultValue: false,
+    },
+  ),
   'external-user-group:ldap:preserveDeletedGroups': defineConfig<boolean>({
   'external-user-group:ldap:preserveDeletedGroups': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),
-  'external-user-group:ldap:groupNameAttribute': defineConfig<string | undefined>({
+  'external-user-group:ldap:groupNameAttribute': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'external-user-group:ldap:groupDescriptionAttribute': defineConfig<string | undefined>({
+  'external-user-group:ldap:groupDescriptionAttribute': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
   'external-user-group:keycloak:host': defineConfig<string | undefined>({
   'external-user-group:keycloak:host': defineConfig<string | undefined>({
@@ -1216,23 +1269,32 @@ export const CONFIG_DEFINITIONS = {
   'external-user-group:keycloak:groupRealm': defineConfig<string | undefined>({
   'external-user-group:keycloak:groupRealm': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'external-user-group:keycloak:groupSyncClientRealm': defineConfig<string | undefined>({
+  'external-user-group:keycloak:groupSyncClientRealm': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'external-user-group:keycloak:groupSyncClientID': defineConfig<string | undefined>({
+  'external-user-group:keycloak:groupSyncClientID': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'external-user-group:keycloak:groupSyncClientSecret': defineConfig<string | undefined>({
+  'external-user-group:keycloak:groupSyncClientSecret': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
     isSecret: true,
     isSecret: true,
   }),
   }),
-  'external-user-group:keycloak:autoGenerateUserOnGroupSync': defineConfig<boolean>({
-    defaultValue: false,
-  }),
+  'external-user-group:keycloak:autoGenerateUserOnGroupSync':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
   'external-user-group:keycloak:preserveDeletedGroups': defineConfig<boolean>({
   'external-user-group:keycloak:preserveDeletedGroups': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),
-  'external-user-group:keycloak:groupDescriptionAttribute': defineConfig<string | undefined>({
+  'external-user-group:keycloak:groupDescriptionAttribute': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
 
 
@@ -1306,7 +1368,11 @@ export const CONFIG_DEFINITIONS = {
 } as const;
 } as const;
 
 
 export type ConfigValues = {
 export type ConfigValues = {
-  [K in ConfigKey]: (typeof CONFIG_DEFINITIONS)[K] extends ConfigDefinition<infer T> ? T : never;
+  [K in ConfigKey]: (typeof CONFIG_DEFINITIONS)[K] extends ConfigDefinition<
+    infer T
+  >
+    ? T
+    : never;
 };
 };
 
 
 // Define groups of settings that use only environment variables
 // Define groups of settings that use only environment variables
@@ -1339,11 +1405,7 @@ export const ENV_ONLY_GROUPS: EnvOnlyGroup[] = [
   },
   },
   {
   {
     controlKey: 'env:useOnlyEnvVars:gcs',
     controlKey: 'env:useOnlyEnvVars:gcs',
-    targetKeys: [
-      'gcs:apiKeyJsonPath',
-      'gcs:bucket',
-      'gcs:uploadNamespace',
-    ],
+    targetKeys: ['gcs:apiKeyJsonPath', 'gcs:bucket', 'gcs:uploadNamespace'],
   },
   },
   {
   {
     controlKey: 'env:useOnlyEnvVars:azure',
     controlKey: 'env:useOnlyEnvVars:azure',

+ 17 - 10
apps/app/src/server/service/config-manager/config-loader.spec.ts

@@ -16,7 +16,7 @@ vi.mock('../../models/config', () => ({
 describe('ConfigLoader', () => {
 describe('ConfigLoader', () => {
   let configLoader: ConfigLoader;
   let configLoader: ConfigLoader;
 
 
-  beforeEach(async() => {
+  beforeEach(async () => {
     configLoader = new ConfigLoader();
     configLoader = new ConfigLoader();
     vi.clearAllMocks();
     vi.clearAllMocks();
   });
   });
@@ -30,8 +30,9 @@ describe('ConfigLoader', () => {
         mockExec.mockResolvedValue(mockDocs);
         mockExec.mockResolvedValue(mockDocs);
       });
       });
 
 
-      it('should return null for value', async() => {
-        const config: RawConfigData<ConfigKey, ConfigValues> = await configLoader.loadFromDB();
+      it('should return null for value', async () => {
+        const config: RawConfigData<ConfigKey, ConfigValues> =
+          await configLoader.loadFromDB();
         expect(config['app:referrerPolicy'].value).toBe(null);
         expect(config['app:referrerPolicy'].value).toBe(null);
       });
       });
     });
     });
@@ -44,8 +45,9 @@ describe('ConfigLoader', () => {
         mockExec.mockResolvedValue(mockDocs);
         mockExec.mockResolvedValue(mockDocs);
       });
       });
 
 
-      it('should return null for value', async() => {
-        const config: RawConfigData<ConfigKey, ConfigValues> = await configLoader.loadFromDB();
+      it('should return null for value', async () => {
+        const config: RawConfigData<ConfigKey, ConfigValues> =
+          await configLoader.loadFromDB();
         expect(config['app:referrerPolicy'].value).toBe(null);
         expect(config['app:referrerPolicy'].value).toBe(null);
       });
       });
     });
     });
@@ -54,13 +56,17 @@ describe('ConfigLoader', () => {
       const validJson = { key: 'value' };
       const validJson = { key: 'value' };
       beforeEach(() => {
       beforeEach(() => {
         const mockDocs = [
         const mockDocs = [
-          { key: 'app:referrerPolicy' as ConfigKey, value: JSON.stringify(validJson) },
+          {
+            key: 'app:referrerPolicy' as ConfigKey,
+            value: JSON.stringify(validJson),
+          },
         ];
         ];
         mockExec.mockResolvedValue(mockDocs);
         mockExec.mockResolvedValue(mockDocs);
       });
       });
 
 
-      it('should return parsed value', async() => {
-        const config: RawConfigData<ConfigKey, ConfigValues> = await configLoader.loadFromDB();
+      it('should return parsed value', async () => {
+        const config: RawConfigData<ConfigKey, ConfigValues> =
+          await configLoader.loadFromDB();
         expect(config['app:referrerPolicy'].value).toEqual(validJson);
         expect(config['app:referrerPolicy'].value).toEqual(validJson);
       });
       });
     });
     });
@@ -73,8 +79,9 @@ describe('ConfigLoader', () => {
         mockExec.mockResolvedValue(mockDocs);
         mockExec.mockResolvedValue(mockDocs);
       });
       });
 
 
-      it('should return null for value', async() => {
-        const config: RawConfigData<ConfigKey, ConfigValues> = await configLoader.loadFromDB();
+      it('should return null for value', async () => {
+        const config: RawConfigData<ConfigKey, ConfigValues> =
+          await configLoader.loadFromDB();
         expect(config['app:referrerPolicy'].value).toBe(null);
         expect(config['app:referrerPolicy'].value).toBe(null);
       });
       });
     });
     });

+ 19 - 14
apps/app/src/server/service/config-manager/config-loader.ts

@@ -9,7 +9,6 @@ import { CONFIG_DEFINITIONS } from './config-definition';
 const logger = loggerFactory('growi:service:ConfigLoader');
 const logger = loggerFactory('growi:service:ConfigLoader');
 
 
 export class ConfigLoader implements IConfigLoader<ConfigKey, ConfigValues> {
 export class ConfigLoader implements IConfigLoader<ConfigKey, ConfigValues> {
-
   async loadFromEnv(): Promise<RawConfigData<ConfigKey, ConfigValues>> {
   async loadFromEnv(): Promise<RawConfigData<ConfigKey, ConfigValues>> {
     const envConfig = {} as RawConfigData<ConfigKey, ConfigValues>;
     const envConfig = {} as RawConfigData<ConfigKey, ConfigValues>;
 
 
@@ -19,7 +18,10 @@ export class ConfigLoader implements IConfigLoader<ConfigKey, ConfigValues> {
       if (metadata.envVarName != null) {
       if (metadata.envVarName != null) {
         const envVarValue = process.env[metadata.envVarName];
         const envVarValue = process.env[metadata.envVarName];
         if (envVarValue != null) {
         if (envVarValue != null) {
-          configValue = this.parseEnvValue(envVarValue, typeof metadata.defaultValue) as ConfigValues[ConfigKey];
+          configValue = this.parseEnvValue(
+            envVarValue,
+            typeof metadata.defaultValue,
+          ) as ConfigValues[ConfigKey];
         }
         }
       }
       }
 
 
@@ -43,15 +45,20 @@ export class ConfigLoader implements IConfigLoader<ConfigKey, ConfigValues> {
 
 
     for (const doc of docs) {
     for (const doc of docs) {
       dbConfig[doc.key as ConfigKey] = {
       dbConfig[doc.key as ConfigKey] = {
-        definition: (doc.key in CONFIG_DEFINITIONS) ? CONFIG_DEFINITIONS[doc.key as ConfigKey] : undefined,
-        value: doc.value != null ? (() => {
-          try {
-            return JSON.parse(doc.value);
-          }
-          catch {
-            return null;
-          }
-        })() : null,
+        definition:
+          doc.key in CONFIG_DEFINITIONS
+            ? CONFIG_DEFINITIONS[doc.key as ConfigKey]
+            : undefined,
+        value:
+          doc.value != null
+            ? (() => {
+                try {
+                  return JSON.parse(doc.value);
+                } catch {
+                  return null;
+                }
+              })()
+            : null,
       };
       };
     }
     }
 
 
@@ -70,13 +77,11 @@ export class ConfigLoader implements IConfigLoader<ConfigKey, ConfigValues> {
       case 'object':
       case 'object':
         try {
         try {
           return JSON.parse(value);
           return JSON.parse(value);
-        }
-        catch {
+        } catch {
           return null;
           return null;
         }
         }
       default:
       default:
         return value;
         return value;
     }
     }
   }
   }
-
 }
 }

+ 100 - 53
apps/app/src/server/service/config-manager/config-manager.integ.ts

@@ -1,31 +1,26 @@
 import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
 import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
 import { mock } from 'vitest-mock-extended';
 import { mock } from 'vitest-mock-extended';
 
 
-
 import { Config } from '../../models/config';
 import { Config } from '../../models/config';
 import type { S2sMessagingService } from '../s2s-messaging/base';
 import type { S2sMessagingService } from '../s2s-messaging/base';
-
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 
 
 describe('ConfigManager', () => {
 describe('ConfigManager', () => {
-
   const s2sMessagingServiceMock = mock<S2sMessagingService>();
   const s2sMessagingServiceMock = mock<S2sMessagingService>();
 
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     configManager.setS2sMessagingService(s2sMessagingServiceMock);
     configManager.setS2sMessagingService(s2sMessagingServiceMock);
   });
   });
 
 
-
   describe("getConfig('app:siteUrl')", () => {
   describe("getConfig('app:siteUrl')", () => {
-
-    beforeEach(async() => {
+    beforeEach(async () => {
       process.env.APP_SITE_URL = 'http://localhost:3000';
       process.env.APP_SITE_URL = 'http://localhost:3000';
 
 
       // remove config from DB
       // remove config from DB
       await Config.deleteOne({ key: 'app:siteUrl' }).exec();
       await Config.deleteOne({ key: 'app:siteUrl' }).exec();
     });
     });
 
 
-    test('returns the env value"', async() => {
+    test('returns the env value"', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
 
 
@@ -36,9 +31,12 @@ describe('ConfigManager', () => {
       expect(value).toEqual('http://localhost:3000');
       expect(value).toEqual('http://localhost:3000');
     });
     });
 
 
-    test('returns the db value"', async() => {
+    test('returns the db value"', async () => {
       // arrange
       // arrange
-      await Config.create({ key: 'app:siteUrl', value: JSON.stringify('https://example.com') });
+      await Config.create({
+        key: 'app:siteUrl',
+        value: JSON.stringify('https://example.com'),
+      });
       await configManager.loadConfigs();
       await configManager.loadConfigs();
 
 
       // act
       // act
@@ -48,10 +46,13 @@ describe('ConfigManager', () => {
       expect(value).toStrictEqual('https://example.com');
       expect(value).toStrictEqual('https://example.com');
     });
     });
 
 
-    test('returns the env value when USES_ONLY_ENV_OPTION is set', async() => {
+    test('returns the env value when USES_ONLY_ENV_OPTION is set', async () => {
       // arrange
       // arrange
       process.env.APP_SITE_URL_USES_ONLY_ENV_VARS = 'true';
       process.env.APP_SITE_URL_USES_ONLY_ENV_VARS = 'true';
-      await Config.create({ key: 'app:siteUrl', value: JSON.stringify('https://example.com') });
+      await Config.create({
+        key: 'app:siteUrl',
+        value: JSON.stringify('https://example.com'),
+      });
       await configManager.loadConfigs();
       await configManager.loadConfigs();
 
 
       // act
       // act
@@ -60,17 +61,17 @@ describe('ConfigManager', () => {
       // assert
       // assert
       expect(value).toEqual('http://localhost:3000');
       expect(value).toEqual('http://localhost:3000');
     });
     });
-
   });
   });
 
 
   describe("getConfig('security:passport-saml:isEnabled')", () => {
   describe("getConfig('security:passport-saml:isEnabled')", () => {
-
-    beforeEach(async() => {
+    beforeEach(async () => {
       // remove config from DB
       // remove config from DB
-      await Config.deleteOne({ key: 'security:passport-saml:isEnabled' }).exec();
+      await Config.deleteOne({
+        key: 'security:passport-saml:isEnabled',
+      }).exec();
     });
     });
 
 
-    test('returns the default value"', async() => {
+    test('returns the default value"', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
 
 
@@ -81,7 +82,7 @@ describe('ConfigManager', () => {
       expect(value).toStrictEqual(false);
       expect(value).toStrictEqual(false);
     });
     });
 
 
-    test('returns the env value"', async() => {
+    test('returns the env value"', async () => {
       // arrange
       // arrange
       process.env.SAML_ENABLED = 'true';
       process.env.SAML_ENABLED = 'true';
       await configManager.loadConfigs();
       await configManager.loadConfigs();
@@ -93,10 +94,13 @@ describe('ConfigManager', () => {
       expect(value).toStrictEqual(true);
       expect(value).toStrictEqual(true);
     });
     });
 
 
-    test('returns the preferred db value"', async() => {
+    test('returns the preferred db value"', async () => {
       // arrange
       // arrange
       process.env.SAML_ENABLED = 'true';
       process.env.SAML_ENABLED = 'true';
-      await Config.create({ key: 'security:passport-saml:isEnabled', value: false });
+      await Config.create({
+        key: 'security:passport-saml:isEnabled',
+        value: false,
+      });
       await configManager.loadConfigs();
       await configManager.loadConfigs();
 
 
       // act
       // act
@@ -108,12 +112,15 @@ describe('ConfigManager', () => {
   });
   });
 
 
   describe('updateConfig', () => {
   describe('updateConfig', () => {
-    beforeEach(async() => {
+    beforeEach(async () => {
       await Config.deleteMany({ key: /app.*/ }).exec();
       await Config.deleteMany({ key: /app.*/ }).exec();
-      await Config.create({ key: 'app:siteUrl', value: JSON.stringify('initial value') });
+      await Config.create({
+        key: 'app:siteUrl',
+        value: JSON.stringify('initial value'),
+      });
     });
     });
 
 
-    test('updates a single config', async() => {
+    test('updates a single config', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
       const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
       const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
@@ -127,21 +134,23 @@ describe('ConfigManager', () => {
       expect(updatedConfig?.value).toEqual(JSON.stringify('updated value'));
       expect(updatedConfig?.value).toEqual(JSON.stringify('updated value'));
     });
     });
 
 
-    test('removes config when value is undefined and removeIfUndefined is true', async() => {
+    test('removes config when value is undefined and removeIfUndefined is true', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
       const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
       const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
       expect(config?.value).toEqual(JSON.stringify('initial value'));
       expect(config?.value).toEqual(JSON.stringify('initial value'));
 
 
       // act
       // act
-      await configManager.updateConfig('app:siteUrl', undefined, { removeIfUndefined: true });
+      await configManager.updateConfig('app:siteUrl', undefined, {
+        removeIfUndefined: true,
+      });
 
 
       // assert
       // assert
       const updatedConfig = await Config.findOne({ key: 'app:siteUrl' }).exec();
       const updatedConfig = await Config.findOne({ key: 'app:siteUrl' }).exec();
       expect(updatedConfig).toBeNull(); // should be removed
       expect(updatedConfig).toBeNull(); // should be removed
     });
     });
 
 
-    test('does not update config when value is undefined and removeIfUndefined is false', async() => {
+    test('does not update config when value is undefined and removeIfUndefined is false', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
       const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
       const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
@@ -157,16 +166,21 @@ describe('ConfigManager', () => {
   });
   });
 
 
   describe('updateConfigs', () => {
   describe('updateConfigs', () => {
-    beforeEach(async() => {
+    beforeEach(async () => {
       await Config.deleteMany({ key: /app.*/ }).exec();
       await Config.deleteMany({ key: /app.*/ }).exec();
-      await Config.create({ key: 'app:siteUrl', value: JSON.stringify('value1') });
+      await Config.create({
+        key: 'app:siteUrl',
+        value: JSON.stringify('value1'),
+      });
     });
     });
 
 
-    test('updates configs in the same namespace', async() => {
+    test('updates configs in the same namespace', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
       const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
       const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
-      const config2 = await Config.findOne({ key: 'app:fileUploadType' }).exec();
+      const config2 = await Config.findOne({
+        key: 'app:fileUploadType',
+      }).exec();
       expect(config1?.value).toEqual(JSON.stringify('value1'));
       expect(config1?.value).toEqual(JSON.stringify('value1'));
       expect(config2).toBeNull();
       expect(config2).toBeNull();
 
 
@@ -175,34 +189,45 @@ describe('ConfigManager', () => {
         'app:siteUrl': 'new value1',
         'app:siteUrl': 'new value1',
         'app:fileUploadType': 'aws',
         'app:fileUploadType': 'aws',
       });
       });
-      const updatedConfig1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
-      const updatedConfig2 = await Config.findOne({ key: 'app:fileUploadType' }).exec();
+      const updatedConfig1 = await Config.findOne({
+        key: 'app:siteUrl',
+      }).exec();
+      const updatedConfig2 = await Config.findOne({
+        key: 'app:fileUploadType',
+      }).exec();
 
 
       // assert
       // assert
       expect(updatedConfig1?.value).toEqual(JSON.stringify('new value1'));
       expect(updatedConfig1?.value).toEqual(JSON.stringify('new value1'));
       expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
       expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
     });
     });
 
 
-    test('removes config when value is undefined and removeIfUndefined is true', async() => {
+    test('removes config when value is undefined and removeIfUndefined is true', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
       const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
       const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
       expect(config1?.value).toEqual(JSON.stringify('value1'));
       expect(config1?.value).toEqual(JSON.stringify('value1'));
 
 
       // act
       // act
-      await configManager.updateConfigs({
-        'app:siteUrl': undefined,
-        'app:fileUploadType': 'aws',
-      }, { removeIfUndefined: true });
+      await configManager.updateConfigs(
+        {
+          'app:siteUrl': undefined,
+          'app:fileUploadType': 'aws',
+        },
+        { removeIfUndefined: true },
+      );
 
 
       // assert
       // assert
-      const updatedConfig1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
-      const updatedConfig2 = await Config.findOne({ key: 'app:fileUploadType' }).exec();
+      const updatedConfig1 = await Config.findOne({
+        key: 'app:siteUrl',
+      }).exec();
+      const updatedConfig2 = await Config.findOne({
+        key: 'app:fileUploadType',
+      }).exec();
       expect(updatedConfig1).toBeNull(); // should be removed
       expect(updatedConfig1).toBeNull(); // should be removed
       expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
       expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
     });
     });
 
 
-    test('does not update config when value is undefined and removeIfUndefined is false', async() => {
+    test('does not update config when value is undefined and removeIfUndefined is false', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
       const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
       const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
@@ -215,37 +240,59 @@ describe('ConfigManager', () => {
       });
       });
 
 
       // assert
       // assert
-      const updatedConfig1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
-      const updatedConfig2 = await Config.findOne({ key: 'app:fileUploadType' }).exec();
+      const updatedConfig1 = await Config.findOne({
+        key: 'app:siteUrl',
+      }).exec();
+      const updatedConfig2 = await Config.findOne({
+        key: 'app:fileUploadType',
+      }).exec();
       expect(updatedConfig1?.value).toEqual(JSON.stringify('value1')); // should remain unchanged
       expect(updatedConfig1?.value).toEqual(JSON.stringify('value1')); // should remain unchanged
       expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
       expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
     });
     });
   });
   });
 
 
   describe('removeConfigs', () => {
   describe('removeConfigs', () => {
-    beforeEach(async() => {
+    beforeEach(async () => {
       await Config.deleteMany({ key: /app.*/ }).exec();
       await Config.deleteMany({ key: /app.*/ }).exec();
-      await Config.create({ key: 'app:serviceType', value: JSON.stringify(GrowiServiceType.onPremise) });
-      await Config.create({ key: 'app:deploymentType', value: JSON.stringify(GrowiDeploymentType.growiDockerCompose) });
+      await Config.create({
+        key: 'app:serviceType',
+        value: JSON.stringify(GrowiServiceType.onPremise),
+      });
+      await Config.create({
+        key: 'app:deploymentType',
+        value: JSON.stringify(GrowiDeploymentType.growiDockerCompose),
+      });
     });
     });
 
 
-    test('removes configs in the same namespace', async() => {
+    test('removes configs in the same namespace', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
       const config3 = await Config.findOne({ key: 'app:serviceType' }).exec();
       const config3 = await Config.findOne({ key: 'app:serviceType' }).exec();
-      const config4 = await Config.findOne({ key: 'app:deploymentType' }).exec();
-      expect(config3?.value).toEqual(JSON.stringify(GrowiServiceType.onPremise));
-      expect(config4?.value).toEqual(JSON.stringify(GrowiDeploymentType.growiDockerCompose));
+      const config4 = await Config.findOne({
+        key: 'app:deploymentType',
+      }).exec();
+      expect(config3?.value).toEqual(
+        JSON.stringify(GrowiServiceType.onPremise),
+      );
+      expect(config4?.value).toEqual(
+        JSON.stringify(GrowiDeploymentType.growiDockerCompose),
+      );
 
 
       // act
       // act
-      await configManager.removeConfigs(['app:serviceType', 'app:deploymentType']);
-      const removedConfig3 = await Config.findOne({ key: 'app:serviceType' }).exec();
-      const removedConfig4 = await Config.findOne({ key: 'app:deploymentType' }).exec();
+      await configManager.removeConfigs([
+        'app:serviceType',
+        'app:deploymentType',
+      ]);
+      const removedConfig3 = await Config.findOne({
+        key: 'app:serviceType',
+      }).exec();
+      const removedConfig4 = await Config.findOne({
+        key: 'app:deploymentType',
+      }).exec();
 
 
       // assert
       // assert
       expect(removedConfig3).toBeNull();
       expect(removedConfig3).toBeNull();
       expect(removedConfig4).toBeNull();
       expect(removedConfig4).toBeNull();
     });
     });
   });
   });
-
 });
 });

+ 77 - 44
apps/app/src/server/service/config-manager/config-manager.spec.ts

@@ -2,7 +2,6 @@ import type { RawConfigData } from '@growi/core/dist/interfaces';
 import { mock } from 'vitest-mock-extended';
 import { mock } from 'vitest-mock-extended';
 
 
 import type { S2sMessagingService } from '../s2s-messaging/base';
 import type { S2sMessagingService } from '../s2s-messaging/base';
-
 import type { ConfigKey, ConfigValues } from './config-definition';
 import type { ConfigKey, ConfigValues } from './config-definition';
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 
 
@@ -20,36 +19,34 @@ vi.mock('../../models/config', () => ({
   Config: mocks.ConfigMock,
   Config: mocks.ConfigMock,
 }));
 }));
 
 
-
 type ConfigManagerToGetLoader = {
 type ConfigManagerToGetLoader = {
   configLoader: { loadFromDB: () => void };
   configLoader: { loadFromDB: () => void };
-}
-
+};
 
 
 describe('ConfigManager test', () => {
 describe('ConfigManager test', () => {
-
   const s2sMessagingServiceMock = mock<S2sMessagingService>();
   const s2sMessagingServiceMock = mock<S2sMessagingService>();
 
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     process.env.CONFIG_PUBSUB_SERVER_TYPE = 'nchan';
     process.env.CONFIG_PUBSUB_SERVER_TYPE = 'nchan';
     configManager.setS2sMessagingService(s2sMessagingServiceMock);
     configManager.setS2sMessagingService(s2sMessagingServiceMock);
   });
   });
 
 
-
   describe('updateConfig()', () => {
   describe('updateConfig()', () => {
-
-    let loadConfigsSpy;
-    beforeEach(async() => {
+    let loadConfigsSpy: ReturnType<typeof vi.spyOn>;
+    beforeEach(async () => {
       loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs');
       loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs');
       // Reset mocks
       // Reset mocks
       mocks.ConfigMock.updateOne.mockClear();
       mocks.ConfigMock.updateOne.mockClear();
       mocks.ConfigMock.deleteOne.mockClear();
       mocks.ConfigMock.deleteOne.mockClear();
     });
     });
 
 
-    test('invoke publishUpdateMessage()', async() => {
+    test('invoke publishUpdateMessage()', async () => {
       // arrenge
       // arrenge
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
-      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+      vi.spyOn(
+        (configManager as unknown as ConfigManagerToGetLoader).configLoader,
+        'loadFromDB',
+      ).mockImplementation(vi.fn());
 
 
       // act
       // act
       await configManager.updateConfig('app:siteUrl', '');
       await configManager.updateConfig('app:siteUrl', '');
@@ -60,10 +57,13 @@ describe('ConfigManager test', () => {
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
     });
 
 
-    test('skip publishUpdateMessage()', async() => {
+    test('skip publishUpdateMessage()', async () => {
       // arrenge
       // arrenge
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
-      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+      vi.spyOn(
+        (configManager as unknown as ConfigManagerToGetLoader).configLoader,
+        'loadFromDB',
+      ).mockImplementation(vi.fn());
 
 
       // act
       // act
       await configManager.updateConfig('app:siteUrl', '', { skipPubsub: true });
       await configManager.updateConfig('app:siteUrl', '', { skipPubsub: true });
@@ -74,26 +74,36 @@ describe('ConfigManager test', () => {
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
     });
     });
 
 
-    test('remove config when value is undefined and removeIfUndefined is true', async() => {
+    test('remove config when value is undefined and removeIfUndefined is true', async () => {
       // arrange
       // arrange
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
-      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+      vi.spyOn(
+        (configManager as unknown as ConfigManagerToGetLoader).configLoader,
+        'loadFromDB',
+      ).mockImplementation(vi.fn());
 
 
       // act
       // act
-      await configManager.updateConfig('app:siteUrl', undefined, { removeIfUndefined: true });
+      await configManager.updateConfig('app:siteUrl', undefined, {
+        removeIfUndefined: true,
+      });
 
 
       // assert
       // assert
       expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledTimes(1);
       expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledTimes(1);
-      expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledWith({ key: 'app:siteUrl' });
+      expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledWith({
+        key: 'app:siteUrl',
+      });
       expect(mocks.ConfigMock.updateOne).not.toHaveBeenCalled();
       expect(mocks.ConfigMock.updateOne).not.toHaveBeenCalled();
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
     });
 
 
-    test('update config with undefined value when removeIfUndefined is false', async() => {
+    test('update config with undefined value when removeIfUndefined is false', async () => {
       // arrange
       // arrange
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
-      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+      vi.spyOn(
+        (configManager as unknown as ConfigManagerToGetLoader).configLoader,
+        'loadFromDB',
+      ).mockImplementation(vi.fn());
 
 
       // act
       // act
       await configManager.updateConfig('app:siteUrl', undefined);
       await configManager.updateConfig('app:siteUrl', undefined);
@@ -109,25 +119,28 @@ describe('ConfigManager test', () => {
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
     });
-
   });
   });
 
 
   describe('updateConfigs()', () => {
   describe('updateConfigs()', () => {
-
-    let loadConfigsSpy;
-    beforeEach(async() => {
+    let loadConfigsSpy: ReturnType<typeof vi.spyOn>;
+    beforeEach(async () => {
       loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs');
       loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs');
       // Reset mocks
       // Reset mocks
       mocks.ConfigMock.bulkWrite.mockClear();
       mocks.ConfigMock.bulkWrite.mockClear();
     });
     });
 
 
-    test('invoke publishUpdateMessage()', async() => {
+    test('invoke publishUpdateMessage()', async () => {
       // arrange
       // arrange
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
-      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+      vi.spyOn(
+        (configManager as unknown as ConfigManagerToGetLoader).configLoader,
+        'loadFromDB',
+      ).mockImplementation(vi.fn());
 
 
       // act
       // act
-      await configManager.updateConfigs({ 'app:siteUrl': 'https://example.com' });
+      await configManager.updateConfigs({
+        'app:siteUrl': 'https://example.com',
+      });
 
 
       // assert
       // assert
       expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
       expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
@@ -135,13 +148,19 @@ describe('ConfigManager test', () => {
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
     });
 
 
-    test('skip publishUpdateMessage()', async() => {
+    test('skip publishUpdateMessage()', async () => {
       // arrange
       // arrange
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
-      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+      vi.spyOn(
+        (configManager as unknown as ConfigManagerToGetLoader).configLoader,
+        'loadFromDB',
+      ).mockImplementation(vi.fn());
 
 
       // act
       // act
-      await configManager.updateConfigs({ 'app:siteUrl': '' }, { skipPubsub: true });
+      await configManager.updateConfigs(
+        { 'app:siteUrl': '' },
+        { skipPubsub: true },
+      );
 
 
       // assert
       // assert
       expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
       expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
@@ -149,10 +168,13 @@ describe('ConfigManager test', () => {
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
     });
     });
 
 
-    test('remove configs when values are undefined and removeIfUndefined is true', async() => {
+    test('remove configs when values are undefined and removeIfUndefined is true', async () => {
       // arrange
       // arrange
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
-      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+      vi.spyOn(
+        (configManager as unknown as ConfigManagerToGetLoader).configLoader,
+        'loadFromDB',
+      ).mockImplementation(vi.fn());
 
 
       // act
       // act
       await configManager.updateConfigs(
       await configManager.updateConfigs(
@@ -164,7 +186,9 @@ describe('ConfigManager test', () => {
       expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
       expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
       const operations = mocks.ConfigMock.bulkWrite.mock.calls[0][0];
       const operations = mocks.ConfigMock.bulkWrite.mock.calls[0][0];
       expect(operations).toHaveLength(2);
       expect(operations).toHaveLength(2);
-      expect(operations[0]).toEqual({ deleteOne: { filter: { key: 'app:siteUrl' } } });
+      expect(operations[0]).toEqual({
+        deleteOne: { filter: { key: 'app:siteUrl' } },
+      });
       expect(operations[1]).toEqual({
       expect(operations[1]).toEqual({
         updateOne: {
         updateOne: {
           filter: { key: 'app:title' },
           filter: { key: 'app:title' },
@@ -176,13 +200,19 @@ describe('ConfigManager test', () => {
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
     });
 
 
-    test('update configs including undefined values when removeIfUndefined is false', async() => {
+    test('update configs including undefined values when removeIfUndefined is false', async () => {
       // arrange
       // arrange
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
-      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+      vi.spyOn(
+        (configManager as unknown as ConfigManagerToGetLoader).configLoader,
+        'loadFromDB',
+      ).mockImplementation(vi.fn());
 
 
       // act
       // act
-      await configManager.updateConfigs({ 'app:siteUrl': undefined, 'app:title': 'GROWI' });
+      await configManager.updateConfigs({
+        'app:siteUrl': undefined,
+        'app:title': 'GROWI',
+      });
 
 
       // assert
       // assert
       expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
       expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
@@ -205,11 +235,10 @@ describe('ConfigManager test', () => {
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
     });
-
   });
   });
 
 
   describe('getManagedEnvVars()', () => {
   describe('getManagedEnvVars()', () => {
-    beforeAll(async() => {
+    beforeAll(async () => {
       process.env.AUTO_INSTALL_ADMIN_USERNAME = 'admin';
       process.env.AUTO_INSTALL_ADMIN_USERNAME = 'admin';
       process.env.AUTO_INSTALL_ADMIN_PASSWORD = 'password';
       process.env.AUTO_INSTALL_ADMIN_PASSWORD = 'password';
 
 
@@ -237,19 +266,22 @@ describe('ConfigManager test', () => {
 
 
   describe('getConfig()', () => {
   describe('getConfig()', () => {
     // Helper function to set configs with proper typing
     // Helper function to set configs with proper typing
-    const setTestConfigs = (dbConfig: Partial<TestConfigData>, envConfig: Partial<TestConfigData>): void => {
+    const setTestConfigs = (
+      dbConfig: Partial<TestConfigData>,
+      envConfig: Partial<TestConfigData>,
+    ): void => {
       Object.defineProperties(configManager, {
       Object.defineProperties(configManager, {
         dbConfig: { value: dbConfig, configurable: true },
         dbConfig: { value: dbConfig, configurable: true },
         envConfig: { value: envConfig, configurable: true },
         envConfig: { value: envConfig, configurable: true },
       });
       });
     };
     };
 
 
-    beforeEach(async() => {
+    beforeEach(async () => {
       // Reset configs before each test using properly typed empty objects
       // Reset configs before each test using properly typed empty objects
       setTestConfigs({}, {});
       setTestConfigs({}, {});
     });
     });
 
 
-    test('should fallback to env value when dbConfig[key] exists but its value is undefined', async() => {
+    test('should fallback to env value when dbConfig[key] exists but its value is undefined', async () => {
       // Prepare test data that simulates the issue with proper typing
       // Prepare test data that simulates the issue with proper typing
       const dbConfig: Partial<TestConfigData> = {
       const dbConfig: Partial<TestConfigData> = {
         'app:title': { value: undefined },
         'app:title': { value: undefined },
@@ -266,7 +298,7 @@ describe('ConfigManager test', () => {
       expect(result).toBe('GROWI');
       expect(result).toBe('GROWI');
     });
     });
 
 
-    test('should handle various edge case scenarios correctly', async() => {
+    test('should handle various edge case scenarios correctly', async () => {
       // Setup multiple test scenarios with proper typing
       // Setup multiple test scenarios with proper typing
       const dbConfig: Partial<TestConfigData> = {
       const dbConfig: Partial<TestConfigData> = {
         'app:title': { value: undefined }, // db value is explicitly undefined
         'app:title': { value: undefined }, // db value is explicitly undefined
@@ -287,10 +319,11 @@ describe('ConfigManager test', () => {
 
 
       // Test each scenario
       // Test each scenario
       expect(configManager.getConfig('app:title')).toBe('GROWI'); // Should fallback to env when db value is undefined
       expect(configManager.getConfig('app:title')).toBe('GROWI'); // Should fallback to env when db value is undefined
-      expect(configManager.getConfig('app:siteUrl')).toBe('https://example.com'); // Should fallback to env when db value is undefined
+      expect(configManager.getConfig('app:siteUrl')).toBe(
+        'https://example.com',
+      ); // Should fallback to env when db value is undefined
       expect(configManager.getConfig('app:fileUpload')).toBe(true); // Should fallback to env when db config is undefined
       expect(configManager.getConfig('app:fileUpload')).toBe(true); // Should fallback to env when db config is undefined
       expect(configManager.getConfig('app:fileUploadType')).toBe('gridfs'); // Should use db value when valid
       expect(configManager.getConfig('app:fileUploadType')).toBe('gridfs'); // Should use db value when valid
     });
     });
   });
   });
-
 });
 });

+ 56 - 36
apps/app/src/server/service/config-manager/config-manager.ts

@@ -1,4 +1,8 @@
-import type { IConfigManager, UpdateConfigOptions, RawConfigData } from '@growi/core/dist/interfaces';
+import type {
+  IConfigManager,
+  RawConfigData,
+  UpdateConfigOptions,
+} from '@growi/core/dist/interfaces';
 import { ConfigSource } from '@growi/core/dist/interfaces';
 import { ConfigSource } from '@growi/core/dist/interfaces';
 import { parseISO } from 'date-fns/parseISO';
 import { parseISO } from 'date-fns/parseISO';
 
 
@@ -7,17 +11,17 @@ import loggerFactory from '~/utils/logger';
 import type S2sMessage from '../../models/vo/s2s-message';
 import type S2sMessage from '../../models/vo/s2s-message';
 import type { S2sMessagingService } from '../s2s-messaging/base';
 import type { S2sMessagingService } from '../s2s-messaging/base';
 import type { S2sMessageHandlable } from '../s2s-messaging/handlable';
 import type { S2sMessageHandlable } from '../s2s-messaging/handlable';
-
 import type { ConfigKey, ConfigValues } from './config-definition';
 import type { ConfigKey, ConfigValues } from './config-definition';
 import { ENV_ONLY_GROUPS } from './config-definition';
 import { ENV_ONLY_GROUPS } from './config-definition';
 import { ConfigLoader } from './config-loader';
 import { ConfigLoader } from './config-loader';
 
 
 const logger = loggerFactory('growi:service:ConfigManager');
 const logger = loggerFactory('growi:service:ConfigManager');
 
 
-export type IConfigManagerForApp = IConfigManager<ConfigKey, ConfigValues>
-
-export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable {
+export type IConfigManagerForApp = IConfigManager<ConfigKey, ConfigValues>;
 
 
+export class ConfigManager
+  implements IConfigManagerForApp, S2sMessageHandlable
+{
   private configLoader: ConfigLoader;
   private configLoader: ConfigLoader;
 
 
   private s2sMessagingService?: S2sMessagingService;
   private s2sMessagingService?: S2sMessagingService;
@@ -48,11 +52,9 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
   async loadConfigs(options?: { source?: ConfigSource }): Promise<void> {
   async loadConfigs(options?: { source?: ConfigSource }): Promise<void> {
     if (options?.source === 'env') {
     if (options?.source === 'env') {
       this.envConfig = await this.configLoader.loadFromEnv();
       this.envConfig = await this.configLoader.loadFromEnv();
-    }
-    else if (options?.source === 'db') {
+    } else if (options?.source === 'db') {
       this.dbConfig = await this.configLoader.loadFromDB();
       this.dbConfig = await this.configLoader.loadFromDB();
-    }
-    else {
+    } else {
       this.envConfig = await this.configLoader.loadFromEnv();
       this.envConfig = await this.configLoader.loadFromEnv();
       this.dbConfig = await this.configLoader.loadFromDB();
       this.dbConfig = await this.configLoader.loadFromDB();
     }
     }
@@ -60,7 +62,10 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     this.lastLoadedAt = new Date();
     this.lastLoadedAt = new Date();
   }
   }
 
 
-  getConfig<K extends ConfigKey>(key: K, source?: ConfigSource): ConfigValues[K] {
+  getConfig<K extends ConfigKey>(
+    key: K,
+    source?: ConfigSource,
+  ): ConfigValues[K] {
     if (source === ConfigSource.env) {
     if (source === ConfigSource.env) {
       if (!this.envConfig) {
       if (!this.envConfig) {
         throw new Error('Config is not loaded');
         throw new Error('Config is not loaded');
@@ -81,7 +86,7 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     return (
     return (
       this.shouldUseEnvOnly(key)
       this.shouldUseEnvOnly(key)
         ? this.envConfig[key]?.value
         ? this.envConfig[key]?.value
-        : this.dbConfig[key]?.value ?? this.envConfig[key]?.value
+        : (this.dbConfig[key]?.value ?? this.envConfig[key]?.value)
     ) as ConfigValues[K];
     ) as ConfigValues[K];
   }
   }
 
 
@@ -107,15 +112,18 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     return this.envConfig[controlKey].value === true;
     return this.envConfig[controlKey].value === true;
   }
   }
 
 
-  async updateConfig<K extends ConfigKey>(key: K, value: ConfigValues[K], options?: UpdateConfigOptions): Promise<void> {
+  async updateConfig<K extends ConfigKey>(
+    key: K,
+    value: ConfigValues[K],
+    options?: UpdateConfigOptions,
+  ): Promise<void> {
     // Dynamic import to avoid loading database modules too early
     // Dynamic import to avoid loading database modules too early
     const { Config } = await import('../../models/config');
     const { Config } = await import('../../models/config');
 
 
     if (options?.removeIfUndefined && value === undefined) {
     if (options?.removeIfUndefined && value === undefined) {
       // remove the config if the value is undefined and removeIfUndefined is true
       // remove the config if the value is undefined and removeIfUndefined is true
       await Config.deleteOne({ key });
       await Config.deleteOne({ key });
-    }
-    else {
+    } else {
       await Config.updateOne(
       await Config.updateOne(
         { key },
         { key },
         { value: JSON.stringify(value) },
         { value: JSON.stringify(value) },
@@ -130,22 +138,25 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     }
     }
   }
   }
 
 
-  async updateConfigs(updates: Partial<{ [K in ConfigKey]: ConfigValues[K] }>, options?: UpdateConfigOptions): Promise<void> {
+  async updateConfigs(
+    updates: Partial<{ [K in ConfigKey]: ConfigValues[K] }>,
+    options?: UpdateConfigOptions,
+  ): Promise<void> {
     // Dynamic import to avoid loading database modules too early
     // Dynamic import to avoid loading database modules too early
     const { Config } = await import('../../models/config');
     const { Config } = await import('../../models/config');
 
 
     const operations = Object.entries(updates).map(([key, value]) => {
     const operations = Object.entries(updates).map(([key, value]) => {
-      return (options?.removeIfUndefined && value === undefined)
-        // remove the config if the value is undefined
-        ? { deleteOne: { filter: { key } } }
-        // update
-        : {
-          updateOne: {
-            filter: { key },
-            update: { value: JSON.stringify(value) },
-            upsert: true,
-          },
-        };
+      return options?.removeIfUndefined && value === undefined
+        ? // remove the config if the value is undefined
+          { deleteOne: { filter: { key } } }
+        : // update
+          {
+            updateOne: {
+              filter: { key },
+              update: { value: JSON.stringify(value) },
+              upsert: true,
+            },
+          };
     });
     });
 
 
     await Config.bulkWrite(operations);
     await Config.bulkWrite(operations);
@@ -156,11 +167,14 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     }
     }
   }
   }
 
 
-  async removeConfigs(keys: ConfigKey[], options?: UpdateConfigOptions): Promise<void> {
+  async removeConfigs(
+    keys: ConfigKey[],
+    options?: UpdateConfigOptions,
+  ): Promise<void> {
     // Dynamic import to avoid loading database modules too early
     // Dynamic import to avoid loading database modules too early
     const { Config } = await import('../../models/config');
     const { Config } = await import('../../models/config');
 
 
-    const operations = keys.map(key => ({
+    const operations = keys.map((key) => ({
       deleteOne: {
       deleteOne: {
         filter: { key },
         filter: { key },
       },
       },
@@ -214,12 +228,16 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
   async publishUpdateMessage(): Promise<void> {
   async publishUpdateMessage(): Promise<void> {
     const { default: S2sMessage } = await import('../../models/vo/s2s-message');
     const { default: S2sMessage } = await import('../../models/vo/s2s-message');
 
 
-    const s2sMessage = new S2sMessage('configUpdated', { updatedAt: new Date() });
+    const s2sMessage = new S2sMessage('configUpdated', {
+      updatedAt: new Date(),
+    });
     try {
     try {
       await this.s2sMessagingService?.publish(s2sMessage);
       await this.s2sMessagingService?.publish(s2sMessage);
-    }
-    catch (e) {
-      logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+    } catch (e) {
+      logger.error(
+        'Failed to publish update message with S2sMessagingService: ',
+        e.message,
+      );
     }
     }
   }
   }
 
 
@@ -231,9 +249,12 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     if (eventName !== 'configUpdated') {
     if (eventName !== 'configUpdated') {
       return false;
       return false;
     }
     }
-    return this.lastLoadedAt == null // loaded for the first time
-      || !('updatedAt' in s2sMessage) // updatedAt is not included in the message
-      || (typeof s2sMessage.updatedAt === 'string' && this.lastLoadedAt < parseISO(s2sMessage.updatedAt));
+    return (
+      this.lastLoadedAt == null || // loaded for the first time
+      !('updatedAt' in s2sMessage) || // updatedAt is not included in the message
+      (typeof s2sMessage.updatedAt === 'string' &&
+        this.lastLoadedAt < parseISO(s2sMessage.updatedAt))
+    );
   }
   }
 
 
   /**
   /**
@@ -243,7 +264,6 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     logger.info('Reload configs by pubsub notification');
     logger.info('Reload configs by pubsub notification');
     return this.loadConfigs();
     return this.loadConfigs();
   }
   }
-
 }
 }
 
 
 // Export singleton instance
 // Export singleton instance

+ 2 - 5
apps/app/src/server/service/cron.ts

@@ -9,7 +9,6 @@ const logger = loggerFactory('growi:service:cron');
  * Base class for services that manage a cronjob
  * Base class for services that manage a cronjob
  */
  */
 abstract class CronService {
 abstract class CronService {
-
   // The current cronjob to manage
   // The current cronjob to manage
   cronJob: ScheduledTask | undefined;
   cronJob: ScheduledTask | undefined;
 
 
@@ -50,16 +49,14 @@ abstract class CronService {
    * @param cronSchedule e.g. '0 1 * * *'
    * @param cronSchedule e.g. '0 1 * * *'
    */
    */
   protected generateCronJob(cronSchedule: string): ScheduledTask {
   protected generateCronJob(cronSchedule: string): ScheduledTask {
-    return nodeCron.schedule(cronSchedule, async() => {
+    return nodeCron.schedule(cronSchedule, async () => {
       try {
       try {
         await this.executeJob();
         await this.executeJob();
-      }
-      catch (e) {
+      } catch (e) {
         logger.error(e);
         logger.error(e);
       }
       }
     });
     });
   }
   }
-
 }
 }
 
 
 export default CronService;
 export default CronService;

+ 36 - 22
apps/app/src/server/service/customize.ts

@@ -1,8 +1,11 @@
-import path from 'path';
-
 import type { ColorScheme } from '@growi/core';
 import type { ColorScheme } from '@growi/core';
 import { getForcedColorScheme } from '@growi/core/dist/utils';
 import { getForcedColorScheme } from '@growi/core/dist/utils';
-import { DefaultThemeMetadata, PresetThemesMetadatas, manifestPath } from '@growi/preset-themes';
+import {
+  DefaultThemeMetadata,
+  manifestPath,
+  PresetThemesMetadatas,
+} from '@growi/preset-themes';
+import path from 'path';
 import uglifycss from 'uglifycss';
 import uglifycss from 'uglifycss';
 
 
 import { growiPluginService } from '~/features/growi-plugin/server/services';
 import { growiPluginService } from '~/features/growi-plugin/server/services';
@@ -10,20 +13,15 @@ import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../crowi';
 import type Crowi from '../crowi';
 import S2sMessage from '../models/vo/s2s-message';
 import S2sMessage from '../models/vo/s2s-message';
-
-
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 
-
 const logger = loggerFactory('growi:service:CustomizeService');
 const logger = loggerFactory('growi:service:CustomizeService');
 
 
-
 /**
 /**
  * the service class of CustomizeService
  * the service class of CustomizeService
  */
  */
 export class CustomizeService implements S2sMessageHandlable {
 export class CustomizeService implements S2sMessageHandlable {
-
   s2sMessagingService: any;
   s2sMessagingService: any;
 
 
   appService: any;
   appService: any;
@@ -54,7 +52,10 @@ export class CustomizeService implements S2sMessageHandlable {
       return false;
       return false;
     }
     }
 
 
-    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
+    return (
+      this.lastLoadedAt == null ||
+      this.lastLoadedAt < new Date(s2sMessage.updatedAt)
+    );
   }
   }
 
 
   /**
   /**
@@ -72,13 +73,17 @@ export class CustomizeService implements S2sMessageHandlable {
     const { s2sMessagingService } = this;
     const { s2sMessagingService } = this;
 
 
     if (s2sMessagingService != null) {
     if (s2sMessagingService != null) {
-      const s2sMessage = new S2sMessage('customizeServiceUpdated', { updatedAt: new Date() });
+      const s2sMessage = new S2sMessage('customizeServiceUpdated', {
+        updatedAt: new Date(),
+      });
 
 
       try {
       try {
         await s2sMessagingService.publish(s2sMessage);
         await s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      } catch (e) {
+        logger.error(
+          'Failed to publish update message with S2sMessagingService: ',
+          e.message,
+        );
       }
       }
     }
     }
   }
   }
@@ -124,27 +129,36 @@ export class CustomizeService implements S2sMessageHandlable {
 
 
     this.theme = theme;
     this.theme = theme;
 
 
-    const resultForThemePlugin = await growiPluginService.findThemePlugin(theme);
+    const resultForThemePlugin =
+      await growiPluginService.findThemePlugin(theme);
 
 
     if (resultForThemePlugin != null) {
     if (resultForThemePlugin != null) {
-      this.forcedColorScheme = getForcedColorScheme(resultForThemePlugin.themeMetadata.schemeType);
+      this.forcedColorScheme = getForcedColorScheme(
+        resultForThemePlugin.themeMetadata.schemeType,
+      );
       this.themeHref = resultForThemePlugin.themeHref;
       this.themeHref = resultForThemePlugin.themeHref;
     }
     }
     // retrieve preset theme
     // retrieve preset theme
     else {
     else {
       // import preset-themes manifest
       // import preset-themes manifest
-      const presetThemesManifest = await import(path.join('@growi/preset-themes', manifestPath)).then(imported => imported.default);
+      const presetThemesManifest = await import(
+        path.join('@growi/preset-themes', manifestPath)
+      ).then((imported) => imported.default);
 
 
-      const themeMetadata = PresetThemesMetadatas.find(p => p.name === theme);
+      const themeMetadata = PresetThemesMetadatas.find((p) => p.name === theme);
       this.forcedColorScheme = getForcedColorScheme(themeMetadata?.schemeType);
       this.forcedColorScheme = getForcedColorScheme(themeMetadata?.schemeType);
 
 
-      const manifestKey = themeMetadata?.manifestKey ?? DefaultThemeMetadata.manifestKey;
-      if (themeMetadata == null || !(themeMetadata.manifestKey in presetThemesManifest)) {
-        logger.warn(`Use default theme because the key for '${theme} does not exist in preset-themes manifest`);
+      const manifestKey =
+        themeMetadata?.manifestKey ?? DefaultThemeMetadata.manifestKey;
+      if (
+        themeMetadata == null ||
+        !(themeMetadata.manifestKey in presetThemesManifest)
+      ) {
+        logger.warn(
+          `Use default theme because the key for '${theme} does not exist in preset-themes manifest`,
+        );
       }
       }
       this.themeHref = `/static/preset-themes/${presetThemesManifest[manifestKey].file}`; // configured by express.static
       this.themeHref = `/static/preset-themes/${presetThemesManifest[manifestKey].file}`; // configured by express.static
     }
     }
-
   }
   }
-
 }
 }

+ 55 - 30
apps/app/src/server/service/export.ts

@@ -1,45 +1,41 @@
+import archiver from 'archiver';
 import fs from 'fs';
 import fs from 'fs';
 import path from 'path';
 import path from 'path';
 import { Readable, Transform } from 'stream';
 import { Readable, Transform } from 'stream';
 
 
-import archiver from 'archiver';
-
 import { toArrayIfNot } from '~/utils/array-utils';
 import { toArrayIfNot } from '~/utils/array-utils';
 import { getGrowiVersion } from '~/utils/growi-version';
 import { getGrowiVersion } from '~/utils/growi-version';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type CollectionProgress from '../models/vo/collection-progress';
 import type CollectionProgress from '../models/vo/collection-progress';
 import CollectionProgressingStatus from '../models/vo/collection-progressing-status';
 import CollectionProgressingStatus from '../models/vo/collection-progressing-status';
-
 import type AppService from './app';
 import type AppService from './app';
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 import type { GrowiBridgeService } from './growi-bridge';
 import type { GrowiBridgeService } from './growi-bridge';
 import { growiInfoService } from './growi-info';
 import { growiInfoService } from './growi-info';
 import type { ZipFileStat } from './interfaces/export';
 import type { ZipFileStat } from './interfaces/export';
 
 
-
 const logger = loggerFactory('growi:services:ExportService');
 const logger = loggerFactory('growi:services:ExportService');
 const { pipeline, finished } = require('stream/promises');
 const { pipeline, finished } = require('stream/promises');
 
 
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 
 
 class ExportProgressingStatus extends CollectionProgressingStatus {
 class ExportProgressingStatus extends CollectionProgressingStatus {
-
   async init() {
   async init() {
     // retrieve total document count from each collections
     // retrieve total document count from each collections
-    const promises = this.progressList.map(async(collectionProgress) => {
-      const collection = mongoose.connection.collection(collectionProgress.collectionName);
+    const promises = this.progressList.map(async (collectionProgress) => {
+      const collection = mongoose.connection.collection(
+        collectionProgress.collectionName,
+      );
       collectionProgress.totalCount = await collection.count();
       collectionProgress.totalCount = await collection.count();
     });
     });
     await Promise.all(promises);
     await Promise.all(promises);
 
 
     this.recalculateTotalCount();
     this.recalculateTotalCount();
   }
   }
-
 }
 }
 
 
 class ExportService {
 class ExportService {
-
   crowi: any;
   crowi: any;
 
 
   appService: AppService;
   appService: AppService;
@@ -83,7 +79,9 @@ class ExportService {
    * @return {object} info for zip files and whether currentProgressingStatus exists
    * @return {object} info for zip files and whether currentProgressingStatus exists
    */
    */
   async getStatus() {
   async getStatus() {
-    const zipFiles = fs.readdirSync(this.baseDir).filter(file => path.extname(file) === '.zip');
+    const zipFiles = fs
+      .readdirSync(this.baseDir)
+      .filter((file) => path.extname(file) === '.zip');
 
 
     // process serially so as not to waste memory
     // process serially so as not to waste memory
     const zipFileStats: Array<ZipFileStat | null> = [];
     const zipFileStats: Array<ZipFileStat | null> = [];
@@ -96,14 +94,16 @@ class ExportService {
     }
     }
 
 
     // filter null object (broken zip)
     // filter null object (broken zip)
-    const filtered = zipFileStats.filter(element => element != null);
+    const filtered = zipFileStats.filter((element) => element != null);
 
 
     const isExporting = this.currentProgressingStatus != null;
     const isExporting = this.currentProgressingStatus != null;
 
 
     return {
     return {
       zipFileStats: filtered,
       zipFileStats: filtered,
       isExporting,
       isExporting,
-      progressList: isExporting ? this.currentProgressingStatus?.progressList : null,
+      progressList: isExporting
+        ? this.currentProgressingStatus?.progressList
+        : null,
     };
     };
   }
   }
 
 
@@ -114,8 +114,13 @@ class ExportService {
    * @return {string} path to meta.json
    * @return {string} path to meta.json
    */
    */
   async createMetaJson(): Promise<string> {
   async createMetaJson(): Promise<string> {
-    const metaJson = path.join(this.baseDir, this.growiBridgeService.getMetaFileName());
-    const writeStream = fs.createWriteStream(metaJson, { encoding: this.growiBridgeService.getEncoding() });
+    const metaJson = path.join(
+      this.baseDir,
+      this.growiBridgeService.getMetaFileName(),
+    );
+    const writeStream = fs.createWriteStream(metaJson, {
+      encoding: this.growiBridgeService.getEncoding(),
+    });
     const passwordSeed = this.crowi.env.PASSWORD_SEED || null;
     const passwordSeed = this.crowi.env.PASSWORD_SEED || null;
 
 
     const metaData = {
     const metaData = {
@@ -211,12 +216,15 @@ class ExportService {
     const transformStream = this.generateTransformStream();
     const transformStream = this.generateTransformStream();
 
 
     // log configuration
     // log configuration
-    const exportProgress = this.currentProgressingStatus?.progressMap[collectionName];
+    const exportProgress =
+      this.currentProgressingStatus?.progressMap[collectionName];
     const logStream = this.generateLogStream(exportProgress);
     const logStream = this.generateLogStream(exportProgress);
 
 
     // create WritableStream
     // create WritableStream
     const jsonFileToWrite = path.join(this.baseDir, `${collectionName}.json`);
     const jsonFileToWrite = path.join(this.baseDir, `${collectionName}.json`);
-    const writeStream = fs.createWriteStream(jsonFileToWrite, { encoding: this.growiBridgeService.getEncoding() });
+    const writeStream = fs.createWriteStream(jsonFileToWrite, {
+      encoding: this.growiBridgeService.getEncoding(),
+    });
 
 
     await pipeline(readStream, logStream, transformStream, writeStream);
     await pipeline(readStream, logStream, transformStream, writeStream);
 
 
@@ -230,12 +238,16 @@ class ExportService {
    * @param {Array.<string>} collections array of collection name
    * @param {Array.<string>} collections array of collection name
    * @return {Array.<ZipFileStat>} info of zip file created
    * @return {Array.<ZipFileStat>} info of zip file created
    */
    */
-  async exportCollectionsToZippedJson(collections: string[]): Promise<ZipFileStat | null> {
+  async exportCollectionsToZippedJson(
+    collections: string[],
+  ): Promise<ZipFileStat | null> {
     const metaJson = await this.createMetaJson();
     const metaJson = await this.createMetaJson();
 
 
     // process serially so as not to waste memory
     // process serially so as not to waste memory
     const jsonFiles: string[] = [];
     const jsonFiles: string[] = [];
-    const jsonFilesPromises = collections.map(collectionName => this.exportCollectionToJson(collectionName));
+    const jsonFilesPromises = collections.map((collectionName) =>
+      this.exportCollectionToJson(collectionName),
+    );
     for await (const jsonFile of jsonFilesPromises) {
     for await (const jsonFile of jsonFilesPromises) {
       jsonFiles.push(jsonFile);
       jsonFiles.push(jsonFile);
     }
     }
@@ -244,14 +256,17 @@ class ExportService {
     this.emitStartZippingEvent();
     this.emitStartZippingEvent();
 
 
     // zip json
     // zip json
-    const configs = jsonFiles.map((jsonFile) => { return { from: jsonFile, as: path.basename(jsonFile) } });
+    const configs = jsonFiles.map((jsonFile) => {
+      return { from: jsonFile, as: path.basename(jsonFile) };
+    });
     // add meta.json in zip
     // add meta.json in zip
     configs.push({ from: metaJson, as: path.basename(metaJson) });
     configs.push({ from: metaJson, as: path.basename(metaJson) });
     // exec zip
     // exec zip
     const zipFile = await this.zipFiles(configs);
     const zipFile = await this.zipFiles(configs);
 
 
     // get stats for the zip file
     // get stats for the zip file
-    const addedZipFileStat = await this.growiBridgeService.parseZipFile(zipFile);
+    const addedZipFileStat =
+      await this.growiBridgeService.parseZipFile(zipFile);
 
 
     // send terminate event
     // send terminate event
     this.emitTerminateEvent(addedZipFileStat);
     this.emitTerminateEvent(addedZipFileStat);
@@ -272,8 +287,7 @@ class ExportService {
     let zipFileStat: ZipFileStat | null;
     let zipFileStat: ZipFileStat | null;
     try {
     try {
       zipFileStat = await this.exportCollectionsToZippedJson(collections);
       zipFileStat = await this.exportCollectionsToZippedJson(collections);
-    }
-    finally {
+    } finally {
       this.currentProgressingStatus = null;
       this.currentProgressingStatus = null;
     }
     }
 
 
@@ -288,7 +302,10 @@ class ExportService {
    * @param {CollectionProgress} collectionProgress
    * @param {CollectionProgress} collectionProgress
    * @param {number} currentCount number of items exported
    * @param {number} currentCount number of items exported
    */
    */
-  logProgress(collectionProgress: CollectionProgress | undefined, currentCount: number): void {
+  logProgress(
+    collectionProgress: CollectionProgress | undefined,
+    currentCount: number,
+  ): void {
     if (collectionProgress == null) return;
     if (collectionProgress == null) return;
 
 
     const output = `${collectionProgress.collectionName}: ${currentCount}/${collectionProgress.totalCount} written`;
     const output = `${collectionProgress.collectionName}: ${currentCount}/${collectionProgress.totalCount} written`;
@@ -334,7 +351,9 @@ class ExportService {
    * @param {object} zipFileStat added zip file status data
    * @param {object} zipFileStat added zip file status data
    */
    */
   emitTerminateEvent(zipFileStat: ZipFileStat | null): void {
   emitTerminateEvent(zipFileStat: ZipFileStat | null): void {
-    this.adminEvent.emit('onTerminateForExport', { addedZipFileStat: zipFileStat });
+    this.adminEvent.emit('onTerminateForExport', {
+      addedZipFileStat: zipFileStat,
+    });
   }
   }
 
 
   /**
   /**
@@ -345,11 +364,14 @@ class ExportService {
    * @return {string} absolute path to the zip file
    * @return {string} absolute path to the zip file
    * @see https://www.archiverjs.com/#quick-start
    * @see https://www.archiverjs.com/#quick-start
    */
    */
-  async zipFiles(_configs: {from: string, as: string}[]): Promise<string> {
+  async zipFiles(_configs: { from: string; as: string }[]): Promise<string> {
     const configs = toArrayIfNot(_configs);
     const configs = toArrayIfNot(_configs);
     const appTitle = this.appService.getAppTitle();
     const appTitle = this.appService.getAppTitle();
-    const timeStamp = (new Date()).getTime();
-    const zipFile = path.join(this.baseDir, `${appTitle}-${timeStamp}.growi.zip`);
+    const timeStamp = new Date().getTime();
+    const zipFile = path.join(
+      this.baseDir,
+      `${appTitle}-${timeStamp}.growi.zip`,
+    );
     const archive = archiver('zip', {
     const archive = archiver('zip', {
       zlib: { level: this.zlibLevel },
       zlib: { level: this.zlibLevel },
     });
     });
@@ -361,7 +383,9 @@ class ExportService {
     });
     });
 
 
     // good practice to catch this error explicitly
     // good practice to catch this error explicitly
-    archive.on('error', (err) => { throw err });
+    archive.on('error', (err) => {
+      throw err;
+    });
 
 
     for (const { from, as } of configs) {
     for (const { from, as } of configs) {
       const input = fs.createReadStream(from);
       const input = fs.createReadStream(from);
@@ -379,7 +403,9 @@ class ExportService {
     // pipe archive data to the file
     // pipe archive data to the file
     await pipeline(archive, output);
     await pipeline(archive, output);
 
 
-    logger.info(`zipped GROWI data into ${zipFile} (${archive.pointer()} bytes)`);
+    logger.info(
+      `zipped GROWI data into ${zipFile} (${archive.pointer()} bytes)`,
+    );
 
 
     // delete json files
     // delete json files
     for (const { from } of configs) {
     for (const { from } of configs) {
@@ -399,7 +425,6 @@ class ExportService {
 
 
     return readable;
     return readable;
   }
   }
-
 }
 }
 
 
 // eslint-disable-next-line import/no-mutable-exports
 // eslint-disable-next-line import/no-mutable-exports

+ 24 - 19
apps/app/src/server/service/external-account.ts

@@ -7,13 +7,11 @@ import loggerFactory from '~/utils/logger';
 import { NullUsernameToBeRegisteredError } from '../models/errors';
 import { NullUsernameToBeRegisteredError } from '../models/errors';
 import type { ExternalAccountDocument } from '../models/external-account';
 import type { ExternalAccountDocument } from '../models/external-account';
 import ExternalAccount from '../models/external-account';
 import ExternalAccount from '../models/external-account';
-
 import type PassportService from './passport';
 import type PassportService from './passport';
 
 
 const logger = loggerFactory('growi:service:external-account-service');
 const logger = loggerFactory('growi:service:external-account-service');
 
 
 class ExternalAccountService {
 class ExternalAccountService {
-
   passportService: PassportService;
   passportService: PassportService;
 
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@@ -22,14 +20,16 @@ class ExternalAccountService {
   }
   }
 
 
   async getOrCreateUser(
   async getOrCreateUser(
-      userInfo: {id: string, username: string, name?: string, email?: string},
-      providerId: IExternalAuthProviderType,
+    userInfo: { id: string; username: string; name?: string; email?: string },
+    providerId: IExternalAuthProviderType,
   ): Promise<ExternalAccountDocument | undefined> {
   ): Promise<ExternalAccountDocument | undefined> {
     // get option
     // get option
-    const isSameUsernameTreatedAsIdenticalUser = this.passportService.isSameUsernameTreatedAsIdenticalUser(providerId);
-    const isSameEmailTreatedAsIdenticalUser = providerId === 'ldap'
-      ? false
-      : this.passportService.isSameEmailTreatedAsIdenticalUser(providerId);
+    const isSameUsernameTreatedAsIdenticalUser =
+      this.passportService.isSameUsernameTreatedAsIdenticalUser(providerId);
+    const isSameEmailTreatedAsIdenticalUser =
+      providerId === 'ldap'
+        ? false
+        : this.passportService.isSameEmailTreatedAsIdenticalUser(providerId);
 
 
     try {
     try {
       // find or register(create) user
       // find or register(create) user
@@ -43,30 +43,35 @@ class ExternalAccountService {
         userInfo.email,
         userInfo.email,
       );
       );
       return externalAccount;
       return externalAccount;
-    }
-    catch (err) {
+    } catch (err) {
       if (err instanceof NullUsernameToBeRegisteredError) {
       if (err instanceof NullUsernameToBeRegisteredError) {
         logger.error(err.message);
         logger.error(err.message);
         throw new ErrorV3(err.message);
         throw new ErrorV3(err.message);
-      }
-      else if (err.name === 'DuplicatedUsernameException') {
-        if (isSameEmailTreatedAsIdenticalUser || isSameUsernameTreatedAsIdenticalUser) {
+      } else if (err.name === 'DuplicatedUsernameException') {
+        if (
+          isSameEmailTreatedAsIdenticalUser ||
+          isSameUsernameTreatedAsIdenticalUser
+        ) {
           // associate to existing user
           // associate to existing user
-          logger.debug(`ExternalAccount '${userInfo.username}' will be created and bound to the exisiting User account`);
+          logger.debug(
+            `ExternalAccount '${userInfo.username}' will be created and bound to the exisiting User account`,
+          );
           return ExternalAccount.associate(providerId, userInfo.id, err.user);
           return ExternalAccount.associate(providerId, userInfo.id, err.user);
         }
         }
         logger.error('provider-DuplicatedUsernameException', providerId);
         logger.error('provider-DuplicatedUsernameException', providerId);
 
 
-        throw new ErrorV3('message.provider_duplicated_username_exception', LoginErrorCode.PROVIDER_DUPLICATED_USERNAME_EXCEPTION,
-          undefined, { failedProviderForDuplicatedUsernameException: providerId });
-      }
-      else if (err.name === 'UserUpperLimitException') {
+        throw new ErrorV3(
+          'message.provider_duplicated_username_exception',
+          LoginErrorCode.PROVIDER_DUPLICATED_USERNAME_EXCEPTION,
+          undefined,
+          { failedProviderForDuplicatedUsernameException: providerId },
+        );
+      } else if (err.name === 'UserUpperLimitException') {
         logger.error(err.message);
         logger.error(err.message);
         throw new ErrorV3(err.message);
         throw new ErrorV3(err.message);
       }
       }
     }
     }
   }
   }
-
 }
 }
 
 
 // eslint-disable-next-line import/no-mutable-exports
 // eslint-disable-next-line import/no-mutable-exports

+ 12 - 8
apps/app/src/server/service/file-uploader-switch.ts

@@ -2,7 +2,6 @@ import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../crowi';
 import type Crowi from '../crowi';
 import S2sMessage from '../models/vo/s2s-message';
 import S2sMessage from '../models/vo/s2s-message';
-
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 import type { S2sMessagingService } from './s2s-messaging/base';
 import type { S2sMessagingService } from './s2s-messaging/base';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
@@ -10,7 +9,6 @@ import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 const logger = loggerFactory('growi:service:FileUploaderSwitch');
 const logger = loggerFactory('growi:service:FileUploaderSwitch');
 
 
 class FileUploaderSwitch implements S2sMessageHandlable {
 class FileUploaderSwitch implements S2sMessageHandlable {
-
   crowi: Crowi;
   crowi: Crowi;
 
 
   s2sMessagingService: S2sMessagingService;
   s2sMessagingService: S2sMessagingService;
@@ -31,7 +29,10 @@ class FileUploaderSwitch implements S2sMessageHandlable {
       return false;
       return false;
     }
     }
 
 
-    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
+    return (
+      this.lastLoadedAt == null ||
+      this.lastLoadedAt < new Date(s2sMessage.updatedAt)
+    );
   }
   }
 
 
   /**
   /**
@@ -47,17 +48,20 @@ class FileUploaderSwitch implements S2sMessageHandlable {
     const { s2sMessagingService } = this;
     const { s2sMessagingService } = this;
 
 
     if (s2sMessagingService != null) {
     if (s2sMessagingService != null) {
-      const s2sMessage = new S2sMessage('fileUploadServiceUpdated', { updatedAt: new Date() });
+      const s2sMessage = new S2sMessage('fileUploadServiceUpdated', {
+        updatedAt: new Date(),
+      });
 
 
       try {
       try {
         await s2sMessagingService.publish(s2sMessage);
         await s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      } catch (e) {
+        logger.error(
+          'Failed to publish update message with S2sMessagingService: ',
+          e.message,
+        );
       }
       }
     }
     }
   }
   }
-
 }
 }
 
 
 module.exports = FileUploaderSwitch;
 module.exports = FileUploaderSwitch;

+ 217 - 112
apps/app/src/server/service/g2g-transfer.ts

@@ -1,15 +1,18 @@
-import type { ReadStream } from 'fs';
-import { createReadStream } from 'fs';
-import { basename } from 'path';
-import type { Readable } from 'stream';
-
 import { ConfigSource } from '@growi/core';
 import { ConfigSource } from '@growi/core';
 import type { IUser } from '@growi/core/dist/interfaces';
 import type { IUser } from '@growi/core/dist/interfaces';
+// biome-ignore lint/style/noRestrictedImports: TODO: check effects of using custom axios
 import rawAxios, { type AxiosRequestConfig } from 'axios';
 import rawAxios, { type AxiosRequestConfig } from 'axios';
 import FormData from 'form-data';
 import FormData from 'form-data';
-import mongoose, { Types as MongooseTypes } from 'mongoose';
+import type { ReadStream } from 'fs';
+import { createReadStream } from 'fs';
+import mongoose, {
+  type HydratedDocument,
+  Types as MongooseTypes,
+} from 'mongoose';
+import { basename } from 'path';
 
 
 import { G2G_PROGRESS_STATUS } from '~/interfaces/g2g-transfer';
 import { G2G_PROGRESS_STATUS } from '~/interfaces/g2g-transfer';
+import type { ITransferKey } from '~/interfaces/transfer-key';
 import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import { ImportMode } from '~/models/admin/import-mode';
 import { ImportMode } from '~/models/admin/import-mode';
 import TransferKeyModel from '~/server/models/transfer-key';
 import TransferKeyModel from '~/server/models/transfer-key';
@@ -22,8 +25,10 @@ import { TransferKey } from '~/utils/vo/transfer-key';
 
 
 import type Crowi from '../crowi';
 import type Crowi from '../crowi';
 import { Attachment } from '../models/attachment';
 import { Attachment } from '../models/attachment';
-import { G2GTransferError, G2GTransferErrorCode } from '../models/vo/g2g-transfer-error';
-
+import {
+  G2GTransferError,
+  G2GTransferErrorCode,
+} from '../models/vo/g2g-transfer-error';
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 import type { ConfigKey } from './config-manager/config-definition';
 import type { ConfigKey } from './config-manager/config-definition';
 import { exportService } from './export';
 import { exportService } from './export';
@@ -58,20 +63,20 @@ const UPLOAD_CONFIG_KEYS = [
 /**
 /**
  * File upload related configs
  * File upload related configs
  */
  */
-type FileUploadConfigs = { [key in typeof UPLOAD_CONFIG_KEYS[number] ]: any; }
+type FileUploadConfigs = { [key in (typeof UPLOAD_CONFIG_KEYS)[number]]: any };
 
 
 /**
 /**
  * Data used for comparing to/from GROWI information
  * Data used for comparing to/from GROWI information
  */
  */
 export type IDataGROWIInfo = {
 export type IDataGROWIInfo = {
   /** GROWI version */
   /** GROWI version */
-  version: string
+  version: string;
   /** Max user count */
   /** Max user count */
-  userUpperLimit: number | null // Handle null as Infinity
+  userUpperLimit: number | null; // Handle null as Infinity
   /** Whether file upload is disabled */
   /** Whether file upload is disabled */
   fileUploadDisabled: boolean;
   fileUploadDisabled: boolean;
   /** Total file size allowed */
   /** Total file size allowed */
-  fileUploadTotalLimit: number | null // Handle null as Infinity
+  fileUploadTotalLimit: number | null; // Handle null as Infinity
   /** Attachment infromation */
   /** Attachment infromation */
   attachmentInfo: {
   attachmentInfo: {
     /** File storage type */
     /** File storage type */
@@ -89,7 +94,7 @@ export type IDataGROWIInfo = {
     /** Azure container name */
     /** Azure container name */
     containerName?: string;
     containerName?: string;
   };
   };
-}
+};
 
 
 /**
 /**
  * File metadata in storage
  * File metadata in storage
@@ -105,7 +110,9 @@ interface FileMeta {
 /**
 /**
  * Return type for {@link Pusher.getTransferability}
  * Return type for {@link Pusher.getTransferability}
  */
  */
-type Transferability = { canTransfer: true; } | { canTransfer: false; reason: string; };
+type Transferability =
+  | { canTransfer: true }
+  | { canTransfer: false; reason: string };
 
 
 /**
 /**
  * G2g transfer pusher
  * G2g transfer pusher
@@ -116,27 +123,30 @@ interface Pusher {
    * @param {TransferKey} tk Transfer key
    * @param {TransferKey} tk Transfer key
    * @param {AxiosRequestConfig} config Axios config
    * @param {AxiosRequestConfig} config Axios config
    */
    */
-  generateAxiosConfig(tk: TransferKey, config: AxiosRequestConfig): AxiosRequestConfig
+  generateAxiosConfig(
+    tk: TransferKey,
+    config: AxiosRequestConfig,
+  ): AxiosRequestConfig;
   /**
   /**
    * Send to-growi a request to get GROWI info
    * Send to-growi a request to get GROWI info
    * @param {TransferKey} tk Transfer key
    * @param {TransferKey} tk Transfer key
    */
    */
-  askGROWIInfo(tk: TransferKey): Promise<IDataGROWIInfo>
+  askGROWIInfo(tk: TransferKey): Promise<IDataGROWIInfo>;
   /**
   /**
    * Check if transfering is proceedable
    * Check if transfering is proceedable
    * @param {IDataGROWIInfo} destGROWIInfo GROWI info from dest GROWI
    * @param {IDataGROWIInfo} destGROWIInfo GROWI info from dest GROWI
    */
    */
-  getTransferability(destGROWIInfo: IDataGROWIInfo): Promise<Transferability>
+  getTransferability(destGROWIInfo: IDataGROWIInfo): Promise<Transferability>;
   /**
   /**
    * List files in the storage
    * List files in the storage
    * @param {TransferKey} tk Transfer key
    * @param {TransferKey} tk Transfer key
    */
    */
-  listFilesInStorage(tk: TransferKey): Promise<FileMeta[]>
+  listFilesInStorage(tk: TransferKey): Promise<FileMeta[]>;
   /**
   /**
    * Transfer all Attachment data to dest GROWI
    * Transfer all Attachment data to dest GROWI
    * @param {TransferKey} tk Transfer key
    * @param {TransferKey} tk Transfer key
    */
    */
-  transferAttachments(tk: TransferKey): Promise<void>
+  transferAttachments(tk: TransferKey): Promise<void>;
   /**
   /**
    * Start transfer data between GROWIs
    * Start transfer data between GROWIs
    * @param {TransferKey} tk TransferKey object
    * @param {TransferKey} tk TransferKey object
@@ -151,7 +161,7 @@ interface Pusher {
     collections: string[],
     collections: string[],
     optionsMap: any,
     optionsMap: any,
     destGROWIInfo: IDataGROWIInfo,
     destGROWIInfo: IDataGROWIInfo,
-  ): Promise<void>
+  ): Promise<void>;
 }
 }
 
 
 /**
 /**
@@ -163,12 +173,12 @@ interface Receiver {
    * @throws {import('../models/vo/g2g-transfer-error').G2GTransferError}
    * @throws {import('../models/vo/g2g-transfer-error').G2GTransferError}
    * @param {string} key Transfer key
    * @param {string} key Transfer key
    */
    */
-  validateTransferKey(key: string): Promise<void>
+  validateTransferKey(key: string): Promise<void>;
   /**
   /**
    * Generate GROWIInfo
    * Generate GROWIInfo
    * @throws {import('../models/vo/g2g-transfer-error').G2GTransferError}
    * @throws {import('../models/vo/g2g-transfer-error').G2GTransferError}
    */
    */
-  answerGROWIInfo(): Promise<IDataGROWIInfo>
+  answerGROWIInfo(): Promise<IDataGROWIInfo>;
   /**
   /**
    * DO NOT USE TransferKeyModel.create() directly, instead, use this method to create a TransferKey document.
    * DO NOT USE TransferKeyModel.create() directly, instead, use this method to create a TransferKey document.
    * This method receives appSiteUrlOrigin to create a TransferKey document and returns generated transfer key string.
    * This method receives appSiteUrlOrigin to create a TransferKey document and returns generated transfer key string.
@@ -176,7 +186,7 @@ interface Receiver {
    * @param {string} appSiteUrlOrigin GROWI app site URL origin
    * @param {string} appSiteUrlOrigin GROWI app site URL origin
    * @returns {string} Transfer key string (e.g. http://localhost:3000__grw_internal_tranferkey__<uuid>)
    * @returns {string} Transfer key string (e.g. http://localhost:3000__grw_internal_tranferkey__<uuid>)
    */
    */
-  createTransferKey(appSiteUrlOrigin: string): Promise<string>
+  createTransferKey(appSiteUrlOrigin: string): Promise<string>;
   /**
   /**
    * Returns a map of collection name and ImportSettings
    * Returns a map of collection name and ImportSettings
    * @param {any[]} innerFileStats
    * @param {any[]} innerFileStats
@@ -186,9 +196,9 @@ interface Receiver {
    */
    */
   getImportSettingMap(
   getImportSettingMap(
     innerFileStats: any[],
     innerFileStats: any[],
-    optionsMap: { [key: string]: GrowiArchiveImportOption; },
+    optionsMap: { [key: string]: GrowiArchiveImportOption },
     operatorUserId: string,
     operatorUserId: string,
-  ): Map<string, ImportSettings>
+  ): Map<string, ImportSettings>;
   /**
   /**
    * Import collections
    * Import collections
    * @param {string} collections Array of collection name
    * @param {string} collections Array of collection name
@@ -199,29 +209,28 @@ interface Receiver {
     collections: string[],
     collections: string[],
     importSettingsMap: Map<string, ImportSettings>,
     importSettingsMap: Map<string, ImportSettings>,
     sourceGROWIUploadConfigs: FileUploadConfigs,
     sourceGROWIUploadConfigs: FileUploadConfigs,
-  ): Promise<void>
+  ): Promise<void>;
   /**
   /**
    * Returns file upload configs
    * Returns file upload configs
    */
    */
-  getFileUploadConfigs(): Promise<FileUploadConfigs>
-    /**
+  getFileUploadConfigs(): Promise<FileUploadConfigs>;
+  /**
    * Update file upload configs
    * Update file upload configs
    * @param fileUploadConfigs  File upload configs
    * @param fileUploadConfigs  File upload configs
    */
    */
-  updateFileUploadConfigs(fileUploadConfigs: FileUploadConfigs): Promise<void>
+  updateFileUploadConfigs(fileUploadConfigs: FileUploadConfigs): Promise<void>;
   /**
   /**
    * Upload attachment file
    * Upload attachment file
    * @param {ReadStream} content Pushed attachment data from source GROWI
    * @param {ReadStream} content Pushed attachment data from source GROWI
    * @param {any} attachmentMap Map-ped Attachment instance
    * @param {any} attachmentMap Map-ped Attachment instance
    */
    */
-  receiveAttachment(content: ReadStream, attachmentMap: any): Promise<void>
+  receiveAttachment(content: ReadStream, attachmentMap: any): Promise<void>;
 }
 }
 
 
 /**
 /**
  * G2g transfer pusher
  * G2g transfer pusher
  */
  */
 export class G2GTransferPusherService implements Pusher {
 export class G2GTransferPusherService implements Pusher {
-
   crowi: Crowi;
   crowi: Crowi;
 
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@@ -229,7 +238,10 @@ export class G2GTransferPusherService implements Pusher {
     this.crowi = crowi;
     this.crowi = crowi;
   }
   }
 
 
-  public generateAxiosConfig(tk: TransferKey, baseConfig: AxiosRequestConfig = {}): AxiosRequestConfig {
+  public generateAxiosConfig(
+    tk: TransferKey,
+    baseConfig: AxiosRequestConfig = {},
+  ): AxiosRequestConfig {
     const { appSiteUrlOrigin, key } = tk;
     const { appSiteUrlOrigin, key } = tk;
 
 
     return {
     return {
@@ -245,16 +257,25 @@ export class G2GTransferPusherService implements Pusher {
 
 
   public async askGROWIInfo(tk: TransferKey): Promise<IDataGROWIInfo> {
   public async askGROWIInfo(tk: TransferKey): Promise<IDataGROWIInfo> {
     try {
     try {
-      const { data: { growiInfo } } = await axios.get('/_api/v3/g2g-transfer/growi-info', this.generateAxiosConfig(tk));
+      const {
+        data: { growiInfo },
+      } = await axios.get(
+        '/_api/v3/g2g-transfer/growi-info',
+        this.generateAxiosConfig(tk),
+      );
       return growiInfo;
       return growiInfo;
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
-      throw new G2GTransferError('Failed to retrieve GROWI info.', G2GTransferErrorCode.FAILED_TO_RETRIEVE_GROWI_INFO);
+      throw new G2GTransferError(
+        'Failed to retrieve GROWI info.',
+        G2GTransferErrorCode.FAILED_TO_RETRIEVE_GROWI_INFO,
+      );
     }
     }
   }
   }
 
 
-  public async getTransferability(destGROWIInfo: IDataGROWIInfo): Promise<Transferability> {
+  public async getTransferability(
+    destGROWIInfo: IDataGROWIInfo,
+  ): Promise<Transferability> {
     const { fileUploadService } = this.crowi;
     const { fileUploadService } = this.crowi;
 
 
     const version = getGrowiVersion();
     const version = getGrowiVersion();
@@ -325,12 +346,19 @@ export class G2GTransferPusherService implements Pusher {
 
 
   public async listFilesInStorage(tk: TransferKey): Promise<FileMeta[]> {
   public async listFilesInStorage(tk: TransferKey): Promise<FileMeta[]> {
     try {
     try {
-      const { data: { files } } = await axios.get<{ files: FileMeta[] }>('/_api/v3/g2g-transfer/files', this.generateAxiosConfig(tk));
+      const {
+        data: { files },
+      } = await axios.get<{ files: FileMeta[] }>(
+        '/_api/v3/g2g-transfer/files',
+        this.generateAxiosConfig(tk),
+      );
       return files;
       return files;
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
-      throw new G2GTransferError('Failed to retrieve file metadata', G2GTransferErrorCode.FAILED_TO_RETRIEVE_FILE_METADATA);
+      throw new G2GTransferError(
+        'Failed to retrieve file metadata',
+        G2GTransferErrorCode.FAILED_TO_RETRIEVE_FILE_METADATA,
+      );
     }
     }
   }
   }
 
 
@@ -381,27 +409,32 @@ export class G2GTransferPusherService implements Pusher {
      * | c.png | 1024 |
      * | c.png | 1024 |
      * | d.png | 2048 |
      * | d.png | 2048 |
      */
      */
-    const filter = filesFromSrcGROWI.length > 0 ? {
-      $and: filesFromSrcGROWI.map(({ name, size }) => ({
-        $or: [
-          { fileName: { $ne: basename(name) } },
-          { fileSize: { $ne: size } },
-        ],
-      })),
-    } : {};
+    const filter =
+      filesFromSrcGROWI.length > 0
+        ? {
+            $and: filesFromSrcGROWI.map(({ name, size }) => ({
+              $or: [
+                { fileName: { $ne: basename(name) } },
+                { fileSize: { $ne: size } },
+              ],
+            })),
+          }
+        : {};
     const attachmentsCursor = await Attachment.find(filter).cursor();
     const attachmentsCursor = await Attachment.find(filter).cursor();
     const batchStream = createBatchStream(BATCH_SIZE);
     const batchStream = createBatchStream(BATCH_SIZE);
 
 
     for await (const attachmentBatch of attachmentsCursor.pipe(batchStream)) {
     for await (const attachmentBatch of attachmentsCursor.pipe(batchStream)) {
       for await (const attachment of attachmentBatch) {
       for await (const attachment of attachmentBatch) {
         logger.debug(`processing attachment: ${attachment}`);
         logger.debug(`processing attachment: ${attachment}`);
-        let fileStream;
+        let fileStream: NodeJS.ReadableStream;
         try {
         try {
           // get read stream of each attachment
           // get read stream of each attachment
           fileStream = await fileUploadService.findDeliveryFile(attachment);
           fileStream = await fileUploadService.findDeliveryFile(attachment);
-        }
-        catch (err) {
-          logger.warn(`Error occured when getting Attachment(ID=${attachment.id}), skipping: `, err);
+        } catch (err) {
+          logger.warn(
+            `Error occured when getting Attachment(ID=${attachment.id}), skipping: `,
+            err,
+          );
           socket?.emit('admin:g2gError', {
           socket?.emit('admin:g2gError', {
             message: `Error occured when uploading Attachment(ID=${attachment.id})`,
             message: `Error occured when uploading Attachment(ID=${attachment.id})`,
             key: `Error occured when uploading Attachment(ID=${attachment.id})`,
             key: `Error occured when uploading Attachment(ID=${attachment.id})`,
@@ -413,9 +446,11 @@ export class G2GTransferPusherService implements Pusher {
         // post each attachment file data to receiver
         // post each attachment file data to receiver
         try {
         try {
           await this.doTransferAttachment(tk, attachment, fileStream);
           await this.doTransferAttachment(tk, attachment, fileStream);
-        }
-        catch (err) {
-          logger.error(`Error occured when uploading attachment(ID=${attachment.id})`, err);
+        } catch (err) {
+          logger.error(
+            `Error occured when uploading attachment(ID=${attachment.id})`,
+            err,
+          );
           socket?.emit('admin:g2gError', {
           socket?.emit('admin:g2gError', {
             message: `Error occured when uploading Attachment(ID=${attachment.id})`,
             message: `Error occured when uploading Attachment(ID=${attachment.id})`,
             key: `Error occured when uploading Attachment(ID=${attachment.id})`,
             key: `Error occured when uploading Attachment(ID=${attachment.id})`,
@@ -428,7 +463,13 @@ export class G2GTransferPusherService implements Pusher {
   }
   }
 
 
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  public async startTransfer(tk: TransferKey, user: any, collections: string[], optionsMap: any, destGROWIInfo: IDataGROWIInfo): Promise<void> {
+  public async startTransfer(
+    tk: TransferKey,
+    user: any,
+    collections: string[],
+    optionsMap: any,
+    destGROWIInfo: IDataGROWIInfo,
+  ): Promise<void> {
     const socket = this.crowi.socketIoService?.getAdminSocket();
     const socket = this.crowi.socketIoService?.getAdminSocket();
 
 
     socket?.emit('admin:g2gProgress', {
     socket?.emit('admin:g2gProgress', {
@@ -438,9 +479,11 @@ export class G2GTransferPusherService implements Pusher {
 
 
     const targetConfigKeys = UPLOAD_CONFIG_KEYS;
     const targetConfigKeys = UPLOAD_CONFIG_KEYS;
 
 
-    const uploadConfigs = Object.fromEntries(targetConfigKeys.map((key) => {
-      return [key, configManager.getConfig(key)];
-    }));
+    const uploadConfigs = Object.fromEntries(
+      targetConfigKeys.map((key) => {
+        return [key, configManager.getConfig(key)];
+      }),
+    );
 
 
     let zipFileStream: ReadStream;
     let zipFileStream: ReadStream;
     try {
     try {
@@ -450,14 +493,16 @@ export class G2GTransferPusherService implements Pusher {
       if (zipFilePath == null) throw new Error('Failed to generate zip file');
       if (zipFilePath == null) throw new Error('Failed to generate zip file');
 
 
       zipFileStream = createReadStream(zipFilePath);
       zipFileStream = createReadStream(zipFilePath);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       socket?.emit('admin:g2gProgress', {
       socket?.emit('admin:g2gProgress', {
         mongo: G2G_PROGRESS_STATUS.ERROR,
         mongo: G2G_PROGRESS_STATUS.ERROR,
         attachments: G2G_PROGRESS_STATUS.PENDING,
         attachments: G2G_PROGRESS_STATUS.PENDING,
       });
       });
-      socket?.emit('admin:g2gError', { message: 'Failed to generate GROWI archive file', key: 'admin:g2g:error_generate_growi_archive' });
+      socket?.emit('admin:g2gError', {
+        message: 'Failed to generate GROWI archive file',
+        key: 'admin:g2g:error_generate_growi_archive',
+      });
       throw err;
       throw err;
     }
     }
 
 
@@ -467,20 +512,30 @@ export class G2GTransferPusherService implements Pusher {
       const form = new FormData();
       const form = new FormData();
 
 
       const appTitle = this.crowi.appService.getAppTitle();
       const appTitle = this.crowi.appService.getAppTitle();
-      form.append('transferDataZipFile', zipFileStream, `${appTitle}-${Date.now}.growi.zip`);
+      form.append(
+        'transferDataZipFile',
+        zipFileStream,
+        `${appTitle}-${Date.now}.growi.zip`,
+      );
       form.append('collections', JSON.stringify(collections));
       form.append('collections', JSON.stringify(collections));
       form.append('optionsMap', JSON.stringify(optionsMap));
       form.append('optionsMap', JSON.stringify(optionsMap));
       form.append('operatorUserId', user._id.toString());
       form.append('operatorUserId', user._id.toString());
       form.append('uploadConfigs', JSON.stringify(uploadConfigs));
       form.append('uploadConfigs', JSON.stringify(uploadConfigs));
-      await rawAxios.post('/_api/v3/g2g-transfer/', form, this.generateAxiosConfig(tk, { headers: form.getHeaders() }));
-    }
-    catch (err) {
+      await rawAxios.post(
+        '/_api/v3/g2g-transfer/',
+        form,
+        this.generateAxiosConfig(tk, { headers: form.getHeaders() }),
+      );
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       socket?.emit('admin:g2gProgress', {
       socket?.emit('admin:g2gProgress', {
         mongo: G2G_PROGRESS_STATUS.ERROR,
         mongo: G2G_PROGRESS_STATUS.ERROR,
         attachments: G2G_PROGRESS_STATUS.PENDING,
         attachments: G2G_PROGRESS_STATUS.PENDING,
       });
       });
-      socket?.emit('admin:g2gError', { message: 'Failed to send GROWI archive file to the destination GROWI', key: 'admin:g2g:error_send_growi_archive' });
+      socket?.emit('admin:g2gError', {
+        message: 'Failed to send GROWI archive file to the destination GROWI',
+        key: 'admin:g2g:error_send_growi_archive',
+      });
       throw err;
       throw err;
     }
     }
 
 
@@ -491,14 +546,16 @@ export class G2GTransferPusherService implements Pusher {
 
 
     try {
     try {
       await this.transferAttachments(tk);
       await this.transferAttachments(tk);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       socket?.emit('admin:g2gProgress', {
       socket?.emit('admin:g2gProgress', {
         mongo: G2G_PROGRESS_STATUS.COMPLETED,
         mongo: G2G_PROGRESS_STATUS.COMPLETED,
         attachments: G2G_PROGRESS_STATUS.ERROR,
         attachments: G2G_PROGRESS_STATUS.ERROR,
       });
       });
-      socket?.emit('admin:g2gError', { message: 'Failed to transfer attachments', key: 'admin:g2g:error_upload_attachment' });
+      socket?.emit('admin:g2gError', {
+        message: 'Failed to transfer attachments',
+        key: 'admin:g2g:error_upload_attachment',
+      });
       throw err;
       throw err;
     }
     }
 
 
@@ -512,24 +569,30 @@ export class G2GTransferPusherService implements Pusher {
    * Transfer attachment to dest GROWI
    * Transfer attachment to dest GROWI
    * @param {TransferKey} tk Transfer key
    * @param {TransferKey} tk Transfer key
    * @param {any} attachment Attachment model instance
    * @param {any} attachment Attachment model instance
-   * @param {Readable} fileStream Attachment data(loaded from storage)
+   * @param {NodeJS.ReadableStream} fileStream Attachment data(loaded from storage)
    */
    */
-  private async doTransferAttachment(tk: TransferKey, attachment: any, fileStream: Readable): Promise<void> {
+  private async doTransferAttachment(
+    tk: TransferKey,
+    attachment: any,
+    fileStream: NodeJS.ReadableStream,
+  ): Promise<void> {
     // Use FormData to immitate browser's form data object
     // Use FormData to immitate browser's form data object
     const form = new FormData();
     const form = new FormData();
 
 
     form.append('content', fileStream, attachment.fileName);
     form.append('content', fileStream, attachment.fileName);
     form.append('attachmentMetadata', JSON.stringify(attachment));
     form.append('attachmentMetadata', JSON.stringify(attachment));
-    await rawAxios.post('/_api/v3/g2g-transfer/attachment', form, this.generateAxiosConfig(tk, { headers: form.getHeaders() }));
+    await rawAxios.post(
+      '/_api/v3/g2g-transfer/attachment',
+      form,
+      this.generateAxiosConfig(tk, { headers: form.getHeaders() }),
+    );
   }
   }
-
 }
 }
 
 
 /**
 /**
  * G2g transfer receiver
  * G2g transfer receiver
  */
  */
 export class G2GTransferReceiverService implements Receiver {
 export class G2GTransferReceiverService implements Receiver {
-
   crowi: Crowi;
   crowi: Crowi;
 
 
   constructor(crowi: Crowi) {
   constructor(crowi: Crowi) {
@@ -545,8 +608,7 @@ export class G2GTransferReceiverService implements Receiver {
 
 
     try {
     try {
       TransferKey.parse(transferKey.keyString);
       TransferKey.parse(transferKey.keyString);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       throw new Error(`Transfer key "${key}" is invalid`);
       throw new Error(`Transfer key "${key}" is invalid`);
     }
     }
@@ -556,7 +618,9 @@ export class G2GTransferReceiverService implements Receiver {
     const { fileUploadService } = this.crowi;
     const { fileUploadService } = this.crowi;
     const version = getGrowiVersion();
     const version = getGrowiVersion();
     const userUpperLimit = configManager.getConfig('security:userUpperLimit');
     const userUpperLimit = configManager.getConfig('security:userUpperLimit');
-    const fileUploadDisabled = configManager.getConfig('app:fileUploadDisabled');
+    const fileUploadDisabled = configManager.getConfig(
+      'app:fileUploadDisabled',
+    );
     const fileUploadTotalLimit = fileUploadService.getFileUploadTotalLimit();
     const fileUploadTotalLimit = fileUploadService.getFileUploadTotalLimit();
     const isWritable = await fileUploadService.isWritable();
     const isWritable = await fileUploadService.isWritable();
 
 
@@ -574,15 +638,23 @@ export class G2GTransferReceiverService implements Receiver {
     switch (attachmentInfo.type) {
     switch (attachmentInfo.type) {
       case 'aws':
       case 'aws':
         attachmentInfo.bucket = configManager.getConfig('aws:s3Bucket');
         attachmentInfo.bucket = configManager.getConfig('aws:s3Bucket');
-        attachmentInfo.customEndpoint = configManager.getConfig('aws:s3CustomEndpoint');
+        attachmentInfo.customEndpoint = configManager.getConfig(
+          'aws:s3CustomEndpoint',
+        );
         break;
         break;
       case 'gcs':
       case 'gcs':
         attachmentInfo.bucket = configManager.getConfig('gcs:bucket');
         attachmentInfo.bucket = configManager.getConfig('gcs:bucket');
-        attachmentInfo.uploadNamespace = configManager.getConfig('gcs:uploadNamespace');
+        attachmentInfo.uploadNamespace = configManager.getConfig(
+          'gcs:uploadNamespace',
+        );
         break;
         break;
       case 'azure':
       case 'azure':
-        attachmentInfo.accountName = configManager.getConfig('azure:storageAccountName');
-        attachmentInfo.containerName = configManager.getConfig('azure:storageContainerName');
+        attachmentInfo.accountName = configManager.getConfig(
+          'azure:storageAccountName',
+        );
+        attachmentInfo.containerName = configManager.getConfig(
+          'azure:storageContainerName',
+        );
         break;
         break;
       default:
       default:
     }
     }
@@ -598,14 +670,20 @@ export class G2GTransferReceiverService implements Receiver {
 
 
   public async createTransferKey(appSiteUrlOrigin: string): Promise<string> {
   public async createTransferKey(appSiteUrlOrigin: string): Promise<string> {
     const uuid = new MongooseTypes.ObjectId().toString();
     const uuid = new MongooseTypes.ObjectId().toString();
-    const transferKeyString = TransferKey.generateKeyString(uuid, appSiteUrlOrigin);
+    const transferKeyString = TransferKey.generateKeyString(
+      uuid,
+      appSiteUrlOrigin,
+    );
 
 
     // Save TransferKey document
     // Save TransferKey document
-    let tkd;
+    let tkd: any;
     try {
     try {
-      tkd = await TransferKeyModel.create({ _id: uuid, keyString: transferKeyString, key: uuid });
-    }
-    catch (err) {
+      tkd = await TransferKeyModel.create({
+        _id: uuid,
+        keyString: transferKeyString,
+        key: uuid,
+      });
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       throw err;
       throw err;
     }
     }
@@ -614,31 +692,50 @@ export class G2GTransferReceiverService implements Receiver {
   }
   }
 
 
   public getImportSettingMap(
   public getImportSettingMap(
-      innerFileStats: any[],
-      optionsMap: { [key: string]: GrowiArchiveImportOption; },
-      operatorUserId: string,
+    innerFileStats: any[],
+    optionsMap: { [key: string]: GrowiArchiveImportOption },
+    operatorUserId: string,
   ): Map<string, ImportSettings> {
   ): Map<string, ImportSettings> {
     const importSettingsMap = new Map<string, ImportSettings>();
     const importSettingsMap = new Map<string, ImportSettings>();
     innerFileStats.forEach(({ fileName, collectionName }) => {
     innerFileStats.forEach(({ fileName, collectionName }) => {
-      const options = new GrowiArchiveImportOption(collectionName, undefined, optionsMap[collectionName]);
-
-      if (collectionName === 'configs' && options.mode !== ImportMode.flushAndInsert) {
-        throw new Error('`flushAndInsert` is only available as an import setting for configs collection');
+      const options = new GrowiArchiveImportOption(
+        collectionName,
+        undefined,
+        optionsMap[collectionName],
+      );
+
+      if (
+        collectionName === 'configs' &&
+        options.mode !== ImportMode.flushAndInsert
+      ) {
+        throw new Error(
+          '`flushAndInsert` is only available as an import setting for configs collection',
+        );
       }
       }
       if (collectionName === 'pages' && options.mode === ImportMode.insert) {
       if (collectionName === 'pages' && options.mode === ImportMode.insert) {
-        throw new Error('`insert` is not available as an import setting for pages collection');
+        throw new Error(
+          '`insert` is not available as an import setting for pages collection',
+        );
       }
       }
       if (collectionName === 'attachmentFiles.chunks') {
       if (collectionName === 'attachmentFiles.chunks') {
-        throw new Error('`attachmentFiles.chunks` must not be transferred. Please omit it from request body `collections`.');
+        throw new Error(
+          '`attachmentFiles.chunks` must not be transferred. Please omit it from request body `collections`.',
+        );
       }
       }
       if (collectionName === 'attachmentFiles.files') {
       if (collectionName === 'attachmentFiles.files') {
-        throw new Error('`attachmentFiles.files` must not be transferred. Please omit it from request body `collections`.');
+        throw new Error(
+          '`attachmentFiles.files` must not be transferred. Please omit it from request body `collections`.',
+        );
       }
       }
 
 
       const importSettings: ImportSettings = {
       const importSettings: ImportSettings = {
         mode: options.mode,
         mode: options.mode,
         jsonFileName: fileName,
         jsonFileName: fileName,
-        overwriteParams: generateOverwriteParams(collectionName, operatorUserId, options),
+        overwriteParams: generateOverwriteParams(
+          collectionName,
+          operatorUserId,
+          options,
+        ),
       };
       };
       importSettingsMap.set(collectionName, importSettings);
       importSettingsMap.set(collectionName, importSettings);
     });
     });
@@ -647,14 +744,15 @@ export class G2GTransferReceiverService implements Receiver {
   }
   }
 
 
   public async importCollections(
   public async importCollections(
-      collections: string[],
-      importSettingsMap: Map<string, ImportSettings>,
-      sourceGROWIUploadConfigs: FileUploadConfigs,
+    collections: string[],
+    importSettingsMap: Map<string, ImportSettings>,
+    sourceGROWIUploadConfigs: FileUploadConfigs,
   ): Promise<void> {
   ): Promise<void> {
     const { appService } = this.crowi;
     const { appService } = this.crowi;
     const importService = getImportService();
     const importService = getImportService();
     /** whether to keep current file upload configs */
     /** whether to keep current file upload configs */
-    const shouldKeepUploadConfigs = configManager.getConfig('app:fileUploadType') !== 'none';
+    const shouldKeepUploadConfigs =
+      configManager.getConfig('app:fileUploadType') !== 'none';
 
 
     if (shouldKeepUploadConfigs) {
     if (shouldKeepUploadConfigs) {
       /** cache file upload configs */
       /** cache file upload configs */
@@ -666,8 +764,7 @@ export class G2GTransferReceiverService implements Receiver {
       // restore file upload config from cache
       // restore file upload config from cache
       await configManager.removeConfigs(UPLOAD_CONFIG_KEYS);
       await configManager.removeConfigs(UPLOAD_CONFIG_KEYS);
       await configManager.updateConfigs(fileUploadConfigs);
       await configManager.updateConfigs(fileUploadConfigs);
-    }
-    else {
+    } else {
       // import mongo collections(overwrites file uplaod configs)
       // import mongo collections(overwrites file uplaod configs)
       await importService.import(collections, importSettingsMap);
       await importService.import(collections, importSettingsMap);
 
 
@@ -680,25 +777,33 @@ export class G2GTransferReceiverService implements Receiver {
   }
   }
 
 
   public async getFileUploadConfigs(): Promise<FileUploadConfigs> {
   public async getFileUploadConfigs(): Promise<FileUploadConfigs> {
-    const fileUploadConfigs = Object.fromEntries(UPLOAD_CONFIG_KEYS.map((key) => {
-      return [key, configManager.getConfig(key, ConfigSource.db)];
-    })) as FileUploadConfigs;
+    const fileUploadConfigs = Object.fromEntries(
+      UPLOAD_CONFIG_KEYS.map((key) => {
+        return [key, configManager.getConfig(key, ConfigSource.db)];
+      }),
+    ) as FileUploadConfigs;
 
 
     return fileUploadConfigs;
     return fileUploadConfigs;
   }
   }
 
 
-  public async updateFileUploadConfigs(fileUploadConfigs: FileUploadConfigs): Promise<void> {
+  public async updateFileUploadConfigs(
+    fileUploadConfigs: FileUploadConfigs,
+  ): Promise<void> {
     const { appService } = this.crowi;
     const { appService } = this.crowi;
 
 
-    await configManager.removeConfigs(Object.keys(fileUploadConfigs) as ConfigKey[]);
+    await configManager.removeConfigs(
+      Object.keys(fileUploadConfigs) as ConfigKey[],
+    );
     await configManager.updateConfigs(fileUploadConfigs);
     await configManager.updateConfigs(fileUploadConfigs);
     await this.crowi.setUpFileUpload(true);
     await this.crowi.setUpFileUpload(true);
     await appService.setupAfterInstall();
     await appService.setupAfterInstall();
   }
   }
 
 
-  public async receiveAttachment(content: ReadStream, attachmentMap): Promise<void> {
+  public async receiveAttachment(
+    content: ReadStream,
+    attachmentMap,
+  ): Promise<void> {
     const { fileUploadService } = this.crowi;
     const { fileUploadService } = this.crowi;
     return fileUploadService.uploadAttachment(content, attachmentMap);
     return fileUploadService.uploadAttachment(content, attachmentMap);
   }
   }
-
 }
 }

+ 18 - 17
apps/app/src/server/service/i18next.ts

@@ -1,9 +1,8 @@
-import path from 'path';
-
 import type { Lang } from '@growi/core';
 import type { Lang } from '@growi/core';
-import type { InitOptions, TFunction, i18n } from 'i18next';
+import type { InitOptions, i18n, TFunction } from 'i18next';
 import { createInstance } from 'i18next';
 import { createInstance } from 'i18next';
 import resourcesToBackend from 'i18next-resources-to-backend';
 import resourcesToBackend from 'i18next-resources-to-backend';
+import path from 'path';
 
 
 import * as i18nextConfig from '^/config/i18next.config';
 import * as i18nextConfig from '^/config/i18next.config';
 
 
@@ -11,18 +10,20 @@ import { resolveFromRoot } from '~/server/util/project-dir-utils';
 
 
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 
 
+const relativePathToLocalesRoot = path.relative(
+  __dirname,
+  resolveFromRoot('public/static/locales'),
+);
 
 
-const relativePathToLocalesRoot = path.relative(__dirname, resolveFromRoot('public/static/locales'));
-
-const initI18next = async(overwriteOpts: InitOptions) => {
+const initI18next = async (overwriteOpts: InitOptions) => {
   const i18nInstance = createInstance();
   const i18nInstance = createInstance();
   await i18nInstance
   await i18nInstance
     .use(
     .use(
-      resourcesToBackend(
-        (language: string, namespace: string) => {
-          return import(path.join(relativePathToLocalesRoot, language, `${namespace}.json`));
-        },
-      ),
+      resourcesToBackend((language: string, namespace: string) => {
+        return import(
+          path.join(relativePathToLocalesRoot, language, `${namespace}.json`)
+        );
+      }),
     )
     )
     .init({
     .init({
       ...i18nextConfig.initOptions,
       ...i18nextConfig.initOptions,
@@ -32,14 +33,14 @@ const initI18next = async(overwriteOpts: InitOptions) => {
 };
 };
 
 
 type Translation = {
 type Translation = {
-  t: TFunction,
-  i18n: i18n
-}
+  t: TFunction;
+  i18n: i18n;
+};
 
 
 type Opts = {
 type Opts = {
-  lang?: Lang,
-  ns?: string | readonly string[],
-}
+  lang?: Lang;
+  ns?: string | readonly string[];
+};
 
 
 export async function getTranslation(opts?: Opts): Promise<Translation> {
 export async function getTranslation(opts?: Opts): Promise<Translation> {
   const globalLang = configManager.getConfig('app:globalLang');
   const globalLang = configManager.getConfig('app:globalLang');

+ 92 - 58
apps/app/src/server/service/in-app-notification.ts

@@ -1,9 +1,7 @@
-import type {
-  HasObjectId, IUser, IPage,
-} from '@growi/core';
+import type { HasObjectId, IPage, IUser } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import { subDays } from 'date-fns/subDays';
 import { subDays } from 'date-fns/subDays';
-import type { Types, FilterQuery, UpdateQuery } from 'mongoose';
+import type { FilterQuery, Types, UpdateQuery } from 'mongoose';
 
 
 import type { IPageBulkExportJob } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import type { IPageBulkExportJob } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import { AllEssentialActions } from '~/interfaces/activity';
 import { AllEssentialActions } from '~/interfaces/activity';
@@ -11,27 +9,21 @@ import type { PaginateResult } from '~/interfaces/in-app-notification';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import type { ActivityDocument } from '~/server/models/activity';
 import type { ActivityDocument } from '~/server/models/activity';
 import type { InAppNotificationDocument } from '~/server/models/in-app-notification';
 import type { InAppNotificationDocument } from '~/server/models/in-app-notification';
-import {
-  InAppNotification,
-} from '~/server/models/in-app-notification';
+import { InAppNotification } from '~/server/models/in-app-notification';
 import InAppNotificationSettings from '~/server/models/in-app-notification-settings';
 import InAppNotificationSettings from '~/server/models/in-app-notification-settings';
 import Subscription from '~/server/models/subscription';
 import Subscription from '~/server/models/subscription';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../crowi';
 import type Crowi from '../crowi';
-
-
 import { generateSnapshot } from './in-app-notification/in-app-notification-utils';
 import { generateSnapshot } from './in-app-notification/in-app-notification-utils';
-import { preNotifyService, type PreNotify } from './pre-notify';
-import { RoomPrefix, getRoomNameWithId } from './socket-io/helper';
-
+import { type PreNotify, preNotifyService } from './pre-notify';
+import { getRoomNameWithId, RoomPrefix } from './socket-io/helper';
 
 
 const { STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 const { STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 
 
 const logger = loggerFactory('growi:service:inAppNotification');
 const logger = loggerFactory('growi:service:inAppNotification');
 
 
 export default class InAppNotificationService {
 export default class InAppNotificationService {
-
   crowi!: Crowi;
   crowi!: Crowi;
 
 
   socketIoService!: any;
   socketIoService!: any;
@@ -52,42 +44,58 @@ export default class InAppNotificationService {
   }
   }
 
 
   initActivityEventListeners(): void {
   initActivityEventListeners(): void {
-    this.activityEvent.on('updated', async(activity: ActivityDocument, target: IUser | IPage | IPageBulkExportJob, preNotify: PreNotify) => {
-      try {
-        const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
-        if (shouldNotification) {
-          await this.createInAppNotification(activity, target, preNotify);
+    this.activityEvent.on(
+      'updated',
+      async (
+        activity: ActivityDocument,
+        target: IUser | IPage | IPageBulkExportJob,
+        preNotify: PreNotify,
+      ) => {
+        try {
+          const shouldNotification =
+            activity != null &&
+            target != null &&
+            (AllEssentialActions as ReadonlyArray<string>).includes(
+              activity.action,
+            );
+          if (shouldNotification) {
+            await this.createInAppNotification(activity, target, preNotify);
+          }
+        } catch (err) {
+          logger.error('Create InAppNotification failed', err);
         }
         }
-      }
-      catch (err) {
-        logger.error('Create InAppNotification failed', err);
-      }
-    });
+      },
+    );
   }
   }
 
 
-  emitSocketIo = async(targetUsers) => {
+  emitSocketIo = async (targetUsers) => {
     if (this.socketIoService.isInitialized) {
     if (this.socketIoService.isInitialized) {
-      targetUsers.forEach(async(userId) => {
-
+      targetUsers.forEach(async (userId) => {
         // emit to the room for each user
         // emit to the room for each user
-        await this.socketIoService.getDefaultSocket()
+        await this.socketIoService
+          .getDefaultSocket()
           .in(getRoomNameWithId(RoomPrefix.USER, userId))
           .in(getRoomNameWithId(RoomPrefix.USER, userId))
           .emit('notificationUpdated');
           .emit('notificationUpdated');
       });
       });
     }
     }
   };
   };
 
 
-  upsertByActivity = async function(
-      users: Types.ObjectId[], activity: ActivityDocument, snapshot: string, createdAt?: Date | null,
-  ): Promise<void> {
-    const {
-      _id: activityId, targetModel, target, action,
-    } = activity;
+  upsertByActivity = async (
+    users: Types.ObjectId[],
+    activity: ActivityDocument,
+    snapshot: string,
+    createdAt?: Date | null,
+  ): Promise<void> => {
+    const { _id: activityId, targetModel, target, action } = activity;
     const now = createdAt || Date.now();
     const now = createdAt || Date.now();
     const lastWeek = subDays(now, 7);
     const lastWeek = subDays(now, 7);
     const operations = users.map((user) => {
     const operations = users.map((user) => {
       const filter: FilterQuery<InAppNotificationDocument> = {
       const filter: FilterQuery<InAppNotificationDocument> = {
-        user, target, action, createdAt: { $gt: lastWeek }, snapshot,
+        user,
+        target,
+        action,
+        createdAt: { $gt: lastWeek },
+        snapshot,
       };
       };
       const parameters: UpdateQuery<InAppNotificationDocument> = {
       const parameters: UpdateQuery<InAppNotificationDocument> = {
         user,
         user,
@@ -113,9 +121,13 @@ export default class InAppNotificationService {
     return;
     return;
   };
   };
 
 
-  getLatestNotificationsByUser = async(
-      userId: Types.ObjectId,
-      queryOptions: {offset: number, limit: number, status?: InAppNotificationStatuses},
+  getLatestNotificationsByUser = async (
+    userId: Types.ObjectId,
+    queryOptions: {
+      offset: number;
+      limit: number;
+      status?: InAppNotificationStatuses;
+    },
   ): Promise<PaginateResult<InAppNotificationDocument>> => {
   ): Promise<PaginateResult<InAppNotificationDocument>> => {
     const { limit, offset, status } = queryOptions;
     const { limit, offset, status } = queryOptions;
 
 
@@ -136,9 +148,7 @@ export default class InAppNotificationService {
             { path: 'user' },
             { path: 'user' },
             {
             {
               path: 'target',
               path: 'target',
-              populate: [
-                { path: 'attachment', strictPopulate: false },
-              ],
+              populate: [{ path: 'attachment', strictPopulate: false }],
             },
             },
             { path: 'activities', populate: { path: 'user' } },
             { path: 'activities', populate: { path: 'user' } },
           ],
           ],
@@ -146,14 +156,16 @@ export default class InAppNotificationService {
       );
       );
 
 
       return paginationResult;
       return paginationResult;
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Error', err);
       logger.error('Error', err);
       throw new Error(err);
       throw new Error(err);
     }
     }
   };
   };
 
 
-  open = async function(user: IUser & HasObjectId, id: Types.ObjectId): Promise<void> {
+  open = async (
+    user: IUser & HasObjectId,
+    id: Types.ObjectId,
+  ): Promise<void> => {
     const query = { _id: id, user: user._id };
     const query = { _id: id, user: user._id };
     const parameters = { status: STATUS_OPENED };
     const parameters = { status: STATUS_OPENED };
     const options = { new: true };
     const options = { new: true };
@@ -162,7 +174,9 @@ export default class InAppNotificationService {
     return;
     return;
   };
   };
 
 
-  updateAllNotificationsAsOpened = async function(user: IUser & HasObjectId): Promise<void> {
+  updateAllNotificationsAsOpened = async (
+    user: IUser & HasObjectId,
+  ): Promise<void> => {
     const filter = { user: user._id, status: STATUS_UNOPENED };
     const filter = { user: user._id, status: STATUS_UNOPENED };
     const options = { status: STATUS_OPENED };
     const options = { status: STATUS_OPENED };
 
 
@@ -170,36 +184,54 @@ export default class InAppNotificationService {
     return;
     return;
   };
   };
 
 
-  getUnreadCountByUser = async function(user: Types.ObjectId): Promise<number| undefined> {
+  getUnreadCountByUser = async (
+    user: Types.ObjectId,
+  ): Promise<number | undefined> => {
     const query = { user, status: STATUS_UNOPENED };
     const query = { user, status: STATUS_UNOPENED };
 
 
     try {
     try {
       const count = await InAppNotification.countDocuments(query);
       const count = await InAppNotification.countDocuments(query);
 
 
       return count;
       return count;
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Error on getUnreadCountByUser', err);
       logger.error('Error on getUnreadCountByUser', err);
       throw err;
       throw err;
     }
     }
   };
   };
 
 
-  createSubscription = async function(userId: Types.ObjectId, pageId: Types.ObjectId, targetRuleName: string): Promise<void> {
+  createSubscription = async (
+    userId: Types.ObjectId,
+    pageId: Types.ObjectId,
+    targetRuleName: string,
+  ): Promise<void> => {
     const query = { userId };
     const query = { userId };
-    const inAppNotificationSettings = await InAppNotificationSettings.findOne(query);
+    const inAppNotificationSettings =
+      await InAppNotificationSettings.findOne(query);
     if (inAppNotificationSettings != null) {
     if (inAppNotificationSettings != null) {
-      const subscribeRule = inAppNotificationSettings.subscribeRules.find(subscribeRule => subscribeRule.name === targetRuleName);
+      const subscribeRule = inAppNotificationSettings.subscribeRules.find(
+        (subscribeRule) => subscribeRule.name === targetRuleName,
+      );
       if (subscribeRule != null && subscribeRule.isEnabled) {
       if (subscribeRule != null && subscribeRule.isEnabled) {
-        await Subscription.subscribeByPageId(userId, pageId, SubscriptionStatusType.SUBSCRIBE);
+        await Subscription.subscribeByPageId(
+          userId,
+          pageId,
+          SubscriptionStatusType.SUBSCRIBE,
+        );
       }
       }
     }
     }
 
 
     return;
     return;
   };
   };
 
 
-  createInAppNotification = async function(activity: ActivityDocument, target: IUser | IPage | IPageBulkExportJob, preNotify: PreNotify): Promise<void> {
-
-    const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
+  createInAppNotification = async function (
+    activity: ActivityDocument,
+    target: IUser | IPage | IPageBulkExportJob,
+    preNotify: PreNotify,
+  ): Promise<void> {
+    const shouldNotification =
+      activity != null &&
+      target != null &&
+      (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
 
 
     const targetModel = activity.targetModel;
     const targetModel = activity.targetModel;
 
 
@@ -210,15 +242,17 @@ export default class InAppNotificationService {
 
 
       await preNotify(props);
       await preNotify(props);
 
 
-      await this.upsertByActivity(props.notificationTargetUsers, activity, snapshot);
+      await this.upsertByActivity(
+        props.notificationTargetUsers,
+        activity,
+        snapshot,
+      );
       await this.emitSocketIo(props.notificationTargetUsers);
       await this.emitSocketIo(props.notificationTargetUsers);
-    }
-    else {
+    } else {
       throw Error('no activity to notify');
       throw Error('no activity to notify');
     }
     }
     return;
     return;
   };
   };
-
 }
 }
 
 
 module.exports = InAppNotificationService;
 module.exports = InAppNotificationService;

+ 74 - 47
apps/app/src/server/service/installer.ts

@@ -1,31 +1,25 @@
-import path from 'path';
-
-import type {
-  Lang, IPage, IUser,
-} from '@growi/core';
+import type { IPage, IUser, Lang } from '@growi/core';
 import { addSeconds } from 'date-fns/addSeconds';
 import { addSeconds } from 'date-fns/addSeconds';
 import ExtensibleCustomError from 'extensible-custom-error';
 import ExtensibleCustomError from 'extensible-custom-error';
 import fs from 'graceful-fs';
 import fs from 'graceful-fs';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
+import path from 'path';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../crowi';
 import type Crowi from '../crowi';
-
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 
 
 const logger = loggerFactory('growi:service:installer');
 const logger = loggerFactory('growi:service:installer');
 
 
-export class FailedToCreateAdminUserError extends ExtensibleCustomError {
-}
+export class FailedToCreateAdminUserError extends ExtensibleCustomError {}
 
 
 export type AutoInstallOptions = {
 export type AutoInstallOptions = {
-  allowGuestMode?: boolean,
-  serverDate?: Date,
-}
+  allowGuestMode?: boolean;
+  serverDate?: Date;
+};
 
 
 export class InstallerService {
 export class InstallerService {
-
   crowi: Crowi;
   crowi: Crowi;
 
 
   constructor(crowi: Crowi) {
   constructor(crowi: Crowi) {
@@ -41,25 +35,26 @@ export class InstallerService {
 
 
     try {
     try {
       await searchService.rebuildIndex();
       await searchService.rebuildIndex();
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Rebuild index failed', err);
       logger.error('Rebuild index failed', err);
     }
     }
   }
   }
 
 
-  private async createPage(filePath, pagePath): Promise<IPage|undefined> {
+  private async createPage(filePath, pagePath): Promise<IPage | undefined> {
     const { pageService } = this.crowi;
     const { pageService } = this.crowi;
 
 
     try {
     try {
       const markdown = fs.readFileSync(filePath);
       const markdown = fs.readFileSync(filePath);
       return pageService.forceCreateBySystem(pagePath, markdown.toString(), {});
       return pageService.forceCreateBySystem(pagePath, markdown.toString(), {});
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(`Failed to create ${pagePath}`, err);
       logger.error(`Failed to create ${pagePath}`, err);
     }
     }
   }
   }
 
 
-  private async createInitialPages(lang: Lang, initialPagesCreatedAt?: Date): Promise<any> {
+  private async createInitialPages(
+    lang: Lang,
+    initialPagesCreatedAt?: Date,
+  ): Promise<any> {
     const { localeDir } = this.crowi;
     const { localeDir } = this.crowi;
     // create /Sandbox/*
     // create /Sandbox/*
     /*
     /*
@@ -68,10 +63,22 @@ export class InstallerService {
      *   2. avoid difference for order in VRT
      *   2. avoid difference for order in VRT
      */
      */
     await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox');
     await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox');
-    await this.createPage(path.join(localeDir, lang, 'sandbox-markdown.md'), '/Sandbox/Markdown');
-    await this.createPage(path.join(localeDir, lang, 'sandbox-bootstrap5.md'), '/Sandbox/Bootstrap5');
-    await this.createPage(path.join(localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams');
-    await this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math');
+    await this.createPage(
+      path.join(localeDir, lang, 'sandbox-markdown.md'),
+      '/Sandbox/Markdown',
+    );
+    await this.createPage(
+      path.join(localeDir, lang, 'sandbox-bootstrap5.md'),
+      '/Sandbox/Bootstrap5',
+    );
+    await this.createPage(
+      path.join(localeDir, lang, 'sandbox-diagrams.md'),
+      '/Sandbox/Diagrams',
+    );
+    await this.createPage(
+      path.join(localeDir, lang, 'sandbox-math.md'),
+      '/Sandbox/Math',
+    );
 
 
     // update createdAt and updatedAt fields of all pages
     // update createdAt and updatedAt fields of all pages
     if (initialPagesCreatedAt != null) {
     if (initialPagesCreatedAt != null) {
@@ -81,8 +88,13 @@ export class InstallerService {
         const Page = mongoose.model('Page') as any;
         const Page = mongoose.model('Page') as any;
 
 
         // Increment timestamp to avoid difference for order in VRT
         // Increment timestamp to avoid difference for order in VRT
-        const pagePaths = ['/Sandbox', '/Sandbox/Bootstrap4', '/Sandbox/Diagrams', '/Sandbox/Math'];
-        const promises = pagePaths.map(async(path: string, idx: number) => {
+        const pagePaths = [
+          '/Sandbox',
+          '/Sandbox/Bootstrap4',
+          '/Sandbox/Diagrams',
+          '/Sandbox/Math',
+        ];
+        const promises = pagePaths.map(async (path: string, idx: number) => {
           const date = addSeconds(initialPagesCreatedAt, idx);
           const date = addSeconds(initialPagesCreatedAt, idx);
           return Page.update(
           return Page.update(
             { path },
             { path },
@@ -93,16 +105,14 @@ export class InstallerService {
           );
           );
         });
         });
         await Promise.all(promises);
         await Promise.all(promises);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Failed to update createdAt', err);
         logger.error('Failed to update createdAt', err);
       }
       }
     }
     }
 
 
     try {
     try {
       await this.initSearchIndex();
       await this.initSearchIndex();
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Failed to build Elasticsearch Indices', err);
       logger.error('Failed to build Elasticsearch Indices', err);
     }
     }
   }
   }
@@ -110,20 +120,37 @@ export class InstallerService {
   /**
   /**
    * Execute only once for installing application
    * Execute only once for installing application
    */
    */
-  private async initDB(globalLang: Lang, options?: AutoInstallOptions): Promise<void> {
-    await configManager.updateConfigs({
-      'app:installed': true,
-      'app:fileUpload': true,
-      'app:isV5Compatible': true,
-      'app:globalLang': globalLang,
-    }, { skipPubsub: true });
+  private async initDB(
+    globalLang: Lang,
+    options?: AutoInstallOptions,
+  ): Promise<void> {
+    await configManager.updateConfigs(
+      {
+        'app:installed': true,
+        'app:fileUpload': true,
+        'app:isV5Compatible': true,
+        'app:globalLang': globalLang,
+      },
+      { skipPubsub: true },
+    );
 
 
     if (options?.allowGuestMode) {
     if (options?.allowGuestMode) {
-      await configManager.updateConfig('security:restrictGuestMode', 'Readonly', { skipPubsub: true });
+      await configManager.updateConfig(
+        'security:restrictGuestMode',
+        'Readonly',
+        { skipPubsub: true },
+      );
     }
     }
   }
   }
 
 
-  async install(firstAdminUserToSave: Pick<IUser, 'name' | 'username' | 'email' | 'password'>, globalLang: Lang, options?: AutoInstallOptions): Promise<IUser> {
+  async install(
+    firstAdminUserToSave: Pick<
+      IUser,
+      'name' | 'username' | 'email' | 'password'
+    >,
+    globalLang: Lang,
+    options?: AutoInstallOptions,
+  ): Promise<IUser> {
     await this.initDB(globalLang, options);
     await this.initDB(globalLang, options);
 
 
     const User = mongoose.model<IUser, { createUser }>('User');
     const User = mongoose.model<IUser, { createUser }>('User');
@@ -134,30 +161,30 @@ export class InstallerService {
         path.join(this.crowi.localeDir, globalLang, 'welcome.md'),
         path.join(this.crowi.localeDir, globalLang, 'welcome.md'),
         '/',
         '/',
       );
       );
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       throw err;
       throw err;
     }
     }
 
 
     try {
     try {
       // create first admin user
       // create first admin user
-      const {
-        name, username, email, password,
-      } = firstAdminUserToSave;
-      const adminUser = await User.createUser(name, username, email, password, globalLang);
+      const { name, username, email, password } = firstAdminUserToSave;
+      const adminUser = await User.createUser(
+        name,
+        username,
+        email,
+        password,
+        globalLang,
+      );
       await (adminUser as any).asyncGrantAdmin();
       await (adminUser as any).asyncGrantAdmin();
 
 
       // create initial pages
       // create initial pages
       await this.createInitialPages(globalLang, options?.serverDate);
       await this.createInitialPages(globalLang, options?.serverDate);
 
 
       return adminUser;
       return adminUser;
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       throw new FailedToCreateAdminUserError(err);
       throw new FailedToCreateAdminUserError(err);
     }
     }
-
   }
   }
-
 }
 }

+ 81 - 51
apps/app/src/server/service/ldap.ts

@@ -4,25 +4,23 @@ import loggerFactory from '~/utils/logger';
 
 
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 
 
-
 const logger = loggerFactory('growi:service:ldap-service');
 const logger = loggerFactory('growi:service:ldap-service');
 
 
 // @types/ldapjs is outdated, and SearchResultEntry does not exist.
 // @types/ldapjs is outdated, and SearchResultEntry does not exist.
 // Declare it manually in the meantime.
 // Declare it manually in the meantime.
 export interface SearchResultEntry {
 export interface SearchResultEntry {
-  objectName: string // DN
+  objectName: string; // DN
   attributes: {
   attributes: {
-    type: string,
-    values: string | string[]
-  }[]
+    type: string;
+    values: string | string[];
+  }[];
 }
 }
 
 
 /**
 /**
  * Service to connect to LDAP server.
  * Service to connect to LDAP server.
  * User auth using LDAP is done with PassportService, not here.
  * User auth using LDAP is done with PassportService, not here.
-*/
+ */
 class LdapService {
 class LdapService {
-
   client: ldap.Client | null;
   client: ldap.Client | null;
 
 
   searchBase: string;
   searchBase: string;
@@ -33,7 +31,9 @@ class LdapService {
    * @param {string} userBindPassword Necessary when bind type is user bind
    * @param {string} userBindPassword Necessary when bind type is user bind
    */
    */
   initClient(userBindUsername?: string, userBindPassword?: string): void {
   initClient(userBindUsername?: string, userBindPassword?: string): void {
-    const serverUrl = configManager.getConfig('security:passport-ldap:serverUrl');
+    const serverUrl = configManager.getConfig(
+      'security:passport-ldap:serverUrl',
+    );
 
 
     // parse serverUrl
     // parse serverUrl
     // see: https://regex101.com/r/0tuYBB/1
     // see: https://regex101.com/r/0tuYBB/1
@@ -62,7 +62,9 @@ class LdapService {
     const client = this.client;
     const client = this.client;
     if (client == null) throw new Error('LDAP client is not initialized');
     if (client == null) throw new Error('LDAP client is not initialized');
 
 
-    const isLdapEnabled = configManager.getConfig('security:passport-ldap:isEnabled');
+    const isLdapEnabled = configManager.getConfig(
+      'security:passport-ldap:isEnabled',
+    );
     if (!isLdapEnabled) {
     if (!isLdapEnabled) {
       const notEnabledMessage = 'LDAP is not enabled';
       const notEnabledMessage = 'LDAP is not enabled';
       logger.error(notEnabledMessage);
       logger.error(notEnabledMessage);
@@ -70,15 +72,21 @@ class LdapService {
     }
     }
 
 
     // get configurations
     // get configurations
-    const isUserBind = configManager.getConfig('security:passport-ldap:isUserBind');
-    const bindDN = configManager.getConfig('security:passport-ldap:bindDN') ?? '';
-    const bindCredentials = configManager.getConfig('security:passport-ldap:bindDNPassword') ?? '';
+    const isUserBind = configManager.getConfig(
+      'security:passport-ldap:isUserBind',
+    );
+    const bindDN =
+      configManager.getConfig('security:passport-ldap:bindDN') ?? '';
+    const bindCredentials =
+      configManager.getConfig('security:passport-ldap:bindDNPassword') ?? '';
 
 
     // user bind
     // user bind
-    const fixedBindDN = (isUserBind)
+    const fixedBindDN = isUserBind
       ? bindDN.replace(/{{username}}/, userBindUsername)
       ? bindDN.replace(/{{username}}/, userBindUsername)
       : bindDN;
       : bindDN;
-    const fixedBindCredentials = (isUserBind) ? userBindPassword : bindCredentials;
+    const fixedBindCredentials = isUserBind
+      ? userBindPassword
+      : bindCredentials;
 
 
     return new Promise<void>((resolve, reject) => {
     return new Promise<void>((resolve, reject) => {
       client.bind(fixedBindDN, fixedBindCredentials, (err) => {
       client.bind(fixedBindDN, fixedBindCredentials, (err) => {
@@ -97,7 +105,11 @@ class LdapService {
    * @param {string} base Base DN to execute search on
    * @param {string} base Base DN to execute search on
    * @returns {SearchEntry[]} Search result. Default scope is set to 'sub'.
    * @returns {SearchEntry[]} Search result. Default scope is set to 'sub'.
    */
    */
-  search(filter?: string, base?: string, scope: 'sub' | 'base' | 'one' = 'sub'): Promise<SearchResultEntry[]> {
+  search(
+    filter?: string,
+    base?: string,
+    scope: 'sub' | 'base' | 'one' = 'sub',
+  ): Promise<SearchResultEntry[]> {
     const client = this.client;
     const client = this.client;
     if (client == null) throw new Error('LDAP client is not initialized');
     if (client == null) throw new Error('LDAP client is not initialized');
 
 
@@ -109,36 +121,43 @@ class LdapService {
         reject(err);
         reject(err);
       });
       });
 
 
-      client.search(base || this.searchBase, {
-        scope, filter, paged: true, sizeLimit: 200,
-      }, (err, res) => {
-        if (err != null) {
-          reject(err);
-        }
-
-        // @types/ldapjs is outdated, and pojo property (type SearchResultEntry) does not exist.
-        // Typecast to manually declared SearchResultEntry in the meantime.
-        res.on('searchEntry', (entry: any) => {
-          const pojo = entry?.pojo as SearchResultEntry;
-          searchResults.push(pojo);
-        });
-        res.on('error', (err) => {
-          if (err instanceof NoSuchObjectError) {
-            resolve([]);
-          }
-          else {
+      client.search(
+        base || this.searchBase,
+        {
+          scope,
+          filter,
+          paged: true,
+          sizeLimit: 200,
+        },
+        (err, res) => {
+          if (err != null) {
             reject(err);
             reject(err);
           }
           }
-        });
-        res.on('end', (result) => {
-          if (result?.status === 0) {
-            resolve(searchResults);
-          }
-          else {
-            reject(new Error(`LDAP search failed: status code ${result?.status}`));
-          }
-        });
-      });
+
+          // @types/ldapjs is outdated, and pojo property (type SearchResultEntry) does not exist.
+          // Typecast to manually declared SearchResultEntry in the meantime.
+          res.on('searchEntry', (entry: any) => {
+            const pojo = entry?.pojo as SearchResultEntry;
+            searchResults.push(pojo);
+          });
+          res.on('error', (err) => {
+            if (err instanceof NoSuchObjectError) {
+              resolve([]);
+            } else {
+              reject(err);
+            }
+          });
+          res.on('end', (result) => {
+            if (result?.status === 0) {
+              resolve(searchResults);
+            } else {
+              reject(
+                new Error(`LDAP search failed: status code ${result?.status}`),
+              );
+            }
+          });
+        },
+      );
     });
     });
   }
   }
 
 
@@ -146,13 +165,23 @@ class LdapService {
     return this.search(undefined, this.getGroupSearchBase());
     return this.search(undefined, this.getGroupSearchBase());
   }
   }
 
 
-  getArrayValFromSearchResultEntry(entry: SearchResultEntry, attributeType: string | undefined): string[] {
-    const values: string | string[] = entry.attributes.find(attribute => attribute.type === attributeType)?.values || [];
+  getArrayValFromSearchResultEntry(
+    entry: SearchResultEntry,
+    attributeType: string | undefined,
+  ): string[] {
+    const values: string | string[] =
+      entry.attributes.find((attribute) => attribute.type === attributeType)
+        ?.values || [];
     return typeof values === 'string' ? [values] : values;
     return typeof values === 'string' ? [values] : values;
   }
   }
 
 
-  getStringValFromSearchResultEntry(entry: SearchResultEntry, attributeType: string | undefined): string | undefined {
-    const values: string | string[] | undefined = entry.attributes.find(attribute => attribute.type === attributeType)?.values;
+  getStringValFromSearchResultEntry(
+    entry: SearchResultEntry,
+    attributeType: string | undefined,
+  ): string | undefined {
+    const values: string | string[] | undefined = entry.attributes.find(
+      (attribute) => attribute.type === attributeType,
+    )?.values;
     if (typeof values === 'string' || values == null) {
     if (typeof values === 'string' || values == null) {
       return values;
       return values;
     }
     }
@@ -163,11 +192,12 @@ class LdapService {
   }
   }
 
 
   getGroupSearchBase(): string {
   getGroupSearchBase(): string {
-    return configManager.getConfig('external-user-group:ldap:groupSearchBase')
-      ?? configManager.getConfig('security:passport-ldap:groupSearchBase')
-      ?? '';
+    return (
+      configManager.getConfig('external-user-group:ldap:groupSearchBase') ??
+      configManager.getConfig('security:passport-ldap:groupSearchBase') ??
+      ''
+    );
   }
   }
-
 }
 }
 
 
 // export the singleton instance
 // export the singleton instance

+ 37 - 31
apps/app/src/server/service/mail.ts

@@ -1,28 +1,24 @@
-import { promisify } from 'util';
-
 import ejs from 'ejs';
 import ejs from 'ejs';
 import nodemailer from 'nodemailer';
 import nodemailer from 'nodemailer';
+import { promisify } from 'util';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../crowi';
 import type Crowi from '../crowi';
 import S2sMessage from '../models/vo/s2s-message';
 import S2sMessage from '../models/vo/s2s-message';
-
 import type { IConfigManagerForApp } from './config-manager';
 import type { IConfigManagerForApp } from './config-manager';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 
 const logger = loggerFactory('growi:service:mail');
 const logger = loggerFactory('growi:service:mail');
 
 
-
 type MailConfig = {
 type MailConfig = {
-  to?: string,
-  from?: string,
-  text?: string,
-  subject?: string,
-}
+  to?: string;
+  from?: string;
+  text?: string;
+  subject?: string;
+};
 
 
 class MailService implements S2sMessageHandlable {
 class MailService implements S2sMessageHandlable {
-
   appService!: any;
   appService!: any;
 
 
   configManager: IConfigManagerForApp;
   configManager: IConfigManagerForApp;
@@ -57,7 +53,10 @@ class MailService implements S2sMessageHandlable {
       return false;
       return false;
     }
     }
 
 
-    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
+    return (
+      this.lastLoadedAt == null ||
+      this.lastLoadedAt < new Date(s2sMessage.updatedAt)
+    );
   }
   }
 
 
   /**
   /**
@@ -75,18 +74,21 @@ class MailService implements S2sMessageHandlable {
     const { s2sMessagingService } = this;
     const { s2sMessagingService } = this;
 
 
     if (s2sMessagingService != null) {
     if (s2sMessagingService != null) {
-      const s2sMessage = new S2sMessage('mailServiceUpdated', { updatedAt: new Date() });
+      const s2sMessage = new S2sMessage('mailServiceUpdated', {
+        updatedAt: new Date(),
+      });
 
 
       try {
       try {
         await s2sMessagingService.publish(s2sMessage);
         await s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      } catch (e) {
+        logger.error(
+          'Failed to publish update message with S2sMessagingService: ',
+          e.message,
+        );
       }
       }
     }
     }
   }
   }
 
 
-
   initialize() {
   initialize() {
     const { appService, configManager } = this;
     const { appService, configManager } = this;
 
 
@@ -97,15 +99,15 @@ class MailService implements S2sMessageHandlable {
       return;
       return;
     }
     }
 
 
-    const transmissionMethod = configManager.getConfig('mail:transmissionMethod');
+    const transmissionMethod = configManager.getConfig(
+      'mail:transmissionMethod',
+    );
 
 
     if (transmissionMethod === 'smtp') {
     if (transmissionMethod === 'smtp') {
       this.mailer = this.createSMTPClient();
       this.mailer = this.createSMTPClient();
-    }
-    else if (transmissionMethod === 'ses') {
+    } else if (transmissionMethod === 'ses') {
       this.mailer = this.createSESClient();
       this.mailer = this.createSESClient();
-    }
-    else {
+    } else {
       this.mailer = null;
       this.mailer = null;
     }
     }
 
 
@@ -130,7 +132,8 @@ class MailService implements S2sMessageHandlable {
       if (host == null || port == null) {
       if (host == null || port == null) {
         return null;
         return null;
       }
       }
-      option = { // eslint-disable-line no-param-reassign
+      option = {
+        // eslint-disable-line no-param-reassign
         host,
         host,
         port,
         port,
       };
       };
@@ -159,11 +162,14 @@ class MailService implements S2sMessageHandlable {
 
 
     if (!option) {
     if (!option) {
       const accessKeyId = configManager.getConfig('mail:sesAccessKeyId');
       const accessKeyId = configManager.getConfig('mail:sesAccessKeyId');
-      const secretAccessKey = configManager.getConfig('mail:sesSecretAccessKey');
+      const secretAccessKey = configManager.getConfig(
+        'mail:sesSecretAccessKey',
+      );
       if (accessKeyId == null || secretAccessKey == null) {
       if (accessKeyId == null || secretAccessKey == null) {
         return null;
         return null;
       }
       }
-      option = { // eslint-disable-line no-param-reassign
+      option = {
+        // eslint-disable-line no-param-reassign
         accessKeyId,
         accessKeyId,
         secretAccessKey,
         secretAccessKey,
       };
       };
@@ -193,21 +199,21 @@ class MailService implements S2sMessageHandlable {
 
 
   async send(config) {
   async send(config) {
     if (this.mailer == null) {
     if (this.mailer == null) {
-      throw new Error('Mailer is not completed to set up. Please set up SMTP or AWS setting.');
+      throw new Error(
+        'Mailer is not completed to set up. Please set up SMTP or AWS setting.',
+      );
     }
     }
 
 
-    const renderFilePromisified = promisify<string, ejs.Data, string>(ejs.renderFile);
+    const renderFilePromisified = promisify<string, ejs.Data, string>(
+      ejs.renderFile,
+    );
 
 
     const templateVars = config.vars || {};
     const templateVars = config.vars || {};
-    const output = await renderFilePromisified(
-      config.template,
-      templateVars,
-    );
+    const output = await renderFilePromisified(config.template, templateVars);
 
 
     config.text = output;
     config.text = output;
     return this.mailer.sendMail(this.setupMailConfig(config));
     return this.mailer.sendMail(this.setupMailConfig(config));
   }
   }
-
 }
 }
 
 
 module.exports = MailService;
 module.exports = MailService;

Разница между файлами не показана из-за своего большого размера
+ 513 - 206
apps/app/src/server/service/page-grant.ts


+ 80 - 42
apps/app/src/server/service/page-listing/page-listing.integ.ts

@@ -1,9 +1,9 @@
 import type { IPage, IUser } from '@growi/core/dist/interfaces';
 import type { IPage, IUser } from '@growi/core/dist/interfaces';
 import { isValidObjectId } from '@growi/core/dist/utils/objectid-utils';
 import { isValidObjectId } from '@growi/core/dist/utils/objectid-utils';
-import mongoose from 'mongoose';
 import type { HydratedDocument, Model } from 'mongoose';
 import type { HydratedDocument, Model } from 'mongoose';
+import mongoose from 'mongoose';
 
 
-import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
+import { PageActionStage, PageActionType } from '~/interfaces/page-operation';
 import type { PageModel } from '~/server/models/page';
 import type { PageModel } from '~/server/models/page';
 import type { IPageOperation } from '~/server/models/page-operation';
 import type { IPageOperation } from '~/server/models/page-operation';
 
 
@@ -56,7 +56,7 @@ describe('page-listing store integration tests', () => {
     }
     }
   };
   };
 
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     // setup models
     // setup models
     const setupPage = (await import('~/server/models/page')).default;
     const setupPage = (await import('~/server/models/page')).default;
     setupPage(null);
     setupPage(null);
@@ -69,7 +69,7 @@ describe('page-listing store integration tests', () => {
     PageOperation = (await import('~/server/models/page-operation')).default;
     PageOperation = (await import('~/server/models/page-operation')).default;
   });
   });
 
 
-  beforeEach(async() => {
+  beforeEach(async () => {
     // Clean up database
     // Clean up database
     await Page.deleteMany({});
     await Page.deleteMany({});
     await User.deleteMany({});
     await User.deleteMany({});
@@ -96,8 +96,9 @@ describe('page-listing store integration tests', () => {
   });
   });
 
 
   describe('pageListingService.findRootByViewer', () => {
   describe('pageListingService.findRootByViewer', () => {
-    test('should return root page successfully', async() => {
-      const rootPageResult = await pageListingService.findRootByViewer(testUser);
+    test('should return root page successfully', async () => {
+      const rootPageResult =
+        await pageListingService.findRootByViewer(testUser);
 
 
       expect(rootPageResult).toBeDefined();
       expect(rootPageResult).toBeDefined();
       expect(rootPageResult.path).toBe('/');
       expect(rootPageResult.path).toBe('/');
@@ -107,7 +108,7 @@ describe('page-listing store integration tests', () => {
       expect(rootPageResult.descendantCount).toBe(0);
       expect(rootPageResult.descendantCount).toBe(0);
     });
     });
 
 
-    test('should handle error when root page does not exist', async() => {
+    test('should handle error when root page does not exist', async () => {
       // Remove the root page
       // Remove the root page
       await Page.deleteOne({ path: '/' });
       await Page.deleteOne({ path: '/' });
 
 
@@ -115,14 +116,14 @@ describe('page-listing store integration tests', () => {
         await pageListingService.findRootByViewer(testUser);
         await pageListingService.findRootByViewer(testUser);
         // Should not reach here
         // Should not reach here
         expect(true).toBe(false);
         expect(true).toBe(false);
-      }
-      catch (error) {
+      } catch (error) {
         expect(error).toBeDefined();
         expect(error).toBeDefined();
       }
       }
     });
     });
 
 
-    test('should return proper page structure that matches IPageForTreeItem type', async() => {
-      const rootPageResult = await pageListingService.findRootByViewer(testUser);
+    test('should return proper page structure that matches IPageForTreeItem type', async () => {
+      const rootPageResult =
+        await pageListingService.findRootByViewer(testUser);
 
 
       // Use helper function to validate type structure
       // Use helper function to validate type structure
       validatePageForTreeItem(rootPageResult);
       validatePageForTreeItem(rootPageResult);
@@ -133,7 +134,7 @@ describe('page-listing store integration tests', () => {
       expect([null, 1, 2, 3, 4, 5]).toContain(rootPageResult.grant); // Valid grant values
       expect([null, 1, 2, 3, 4, 5]).toContain(rootPageResult.grant); // Valid grant values
     });
     });
 
 
-    test('should work without user (guest access) and return type-safe result', async() => {
+    test('should work without user (guest access) and return type-safe result', async () => {
       const rootPageResult = await pageListingService.findRootByViewer();
       const rootPageResult = await pageListingService.findRootByViewer();
 
 
       validatePageForTreeItem(rootPageResult);
       validatePageForTreeItem(rootPageResult);
@@ -145,7 +146,7 @@ describe('page-listing store integration tests', () => {
   describe('pageListingService.findChildrenByParentPathOrIdAndViewer', () => {
   describe('pageListingService.findChildrenByParentPathOrIdAndViewer', () => {
     let childPage1: HydratedDocument<IPage>;
     let childPage1: HydratedDocument<IPage>;
 
 
-    beforeEach(async() => {
+    beforeEach(async () => {
       // Create child pages
       // Create child pages
       childPage1 = await Page.create({
       childPage1 = await Page.create({
         path: '/child1',
         path: '/child1',
@@ -182,14 +183,15 @@ describe('page-listing store integration tests', () => {
       });
       });
 
 
       // Update root page descendant count
       // Update root page descendant count
-      await Page.updateOne(
-        { _id: rootPage._id },
-        { descendantCount: 2 },
-      );
+      await Page.updateOne({ _id: rootPage._id }, { descendantCount: 2 });
     });
     });
 
 
-    test('should find children by parent path and return type-safe results', async() => {
-      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+    test('should find children by parent path and return type-safe results', async () => {
+      const children =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          '/',
+          testUser,
+        );
 
 
       expect(children).toHaveLength(2);
       expect(children).toHaveLength(2);
       children.forEach((child) => {
       children.forEach((child) => {
@@ -198,8 +200,12 @@ describe('page-listing store integration tests', () => {
       });
       });
     });
     });
 
 
-    test('should find children by parent ID and return type-safe results', async() => {
-      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer(rootPage._id.toString(), testUser);
+    test('should find children by parent ID and return type-safe results', async () => {
+      const children =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          rootPage._id.toString(),
+          testUser,
+        );
 
 
       expect(children).toHaveLength(2);
       expect(children).toHaveLength(2);
       children.forEach((child) => {
       children.forEach((child) => {
@@ -207,8 +213,12 @@ describe('page-listing store integration tests', () => {
       });
       });
     });
     });
 
 
-    test('should handle nested children correctly', async() => {
-      const nestedChildren = await pageListingService.findChildrenByParentPathOrIdAndViewer('/child1', testUser);
+    test('should handle nested children correctly', async () => {
+      const nestedChildren =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          '/child1',
+          testUser,
+        );
 
 
       expect(nestedChildren).toHaveLength(1);
       expect(nestedChildren).toHaveLength(1);
       const grandChild = nestedChildren[0];
       const grandChild = nestedChildren[0];
@@ -216,15 +226,20 @@ describe('page-listing store integration tests', () => {
       expect(grandChild.path).toBe('/child1/grandchild');
       expect(grandChild.path).toBe('/child1/grandchild');
     });
     });
 
 
-    test('should return empty array when no children exist', async() => {
-      const noChildren = await pageListingService.findChildrenByParentPathOrIdAndViewer('/child2', testUser);
+    test('should return empty array when no children exist', async () => {
+      const noChildren =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          '/child2',
+          testUser,
+        );
 
 
       expect(noChildren).toHaveLength(0);
       expect(noChildren).toHaveLength(0);
       expect(Array.isArray(noChildren)).toBe(true);
       expect(Array.isArray(noChildren)).toBe(true);
     });
     });
 
 
-    test('should work without user (guest access)', async() => {
-      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/');
+    test('should work without user (guest access)', async () => {
+      const children =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer('/');
 
 
       expect(children).toHaveLength(2);
       expect(children).toHaveLength(2);
       children.forEach((child) => {
       children.forEach((child) => {
@@ -232,8 +247,12 @@ describe('page-listing store integration tests', () => {
       });
       });
     });
     });
 
 
-    test('should sort children by path in ascending order', async() => {
-      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+    test('should sort children by path in ascending order', async () => {
+      const children =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          '/',
+          testUser,
+        );
 
 
       expect(children).toHaveLength(2);
       expect(children).toHaveLength(2);
       expect(children[0].path).toBe('/child1');
       expect(children[0].path).toBe('/child1');
@@ -244,7 +263,7 @@ describe('page-listing store integration tests', () => {
   describe('pageListingService processData injection', () => {
   describe('pageListingService processData injection', () => {
     let operatingPage: HydratedDocument<IPage>;
     let operatingPage: HydratedDocument<IPage>;
 
 
-    beforeEach(async() => {
+    beforeEach(async () => {
       // Create a page that will have operations
       // Create a page that will have operations
       operatingPage = await Page.create({
       operatingPage = await Page.create({
         path: '/operating-page',
         path: '/operating-page',
@@ -278,11 +297,17 @@ describe('page-listing store integration tests', () => {
       });
       });
     });
     });
 
 
-    test('should inject processData for pages with operations', async() => {
-      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+    test('should inject processData for pages with operations', async () => {
+      const children =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          '/',
+          testUser,
+        );
 
 
       // Find the operating page in results
       // Find the operating page in results
-      const operatingResult = children.find(child => child.path === '/operating-page');
+      const operatingResult = children.find(
+        (child) => child.path === '/operating-page',
+      );
       expect(operatingResult).toBeDefined();
       expect(operatingResult).toBeDefined();
 
 
       // Validate type structure
       // Validate type structure
@@ -295,7 +320,7 @@ describe('page-listing store integration tests', () => {
       }
       }
     });
     });
 
 
-    test('should set processData to undefined for pages without operations', async() => {
+    test('should set processData to undefined for pages without operations', async () => {
       // Create another page without operations
       // Create another page without operations
       await Page.create({
       await Page.create({
         path: '/normal-page',
         path: '/normal-page',
@@ -308,8 +333,14 @@ describe('page-listing store integration tests', () => {
         parent: rootPage._id,
         parent: rootPage._id,
       });
       });
 
 
-      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
-      const normalPage = children.find(child => child.path === '/normal-page');
+      const children =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          '/',
+          testUser,
+        );
+      const normalPage = children.find(
+        (child) => child.path === '/normal-page',
+      );
 
 
       expect(normalPage).toBeDefined();
       expect(normalPage).toBeDefined();
       if (normalPage) {
       if (normalPage) {
@@ -318,7 +349,7 @@ describe('page-listing store integration tests', () => {
       }
       }
     });
     });
 
 
-    test('should maintain type safety with mixed processData scenarios', async() => {
+    test('should maintain type safety with mixed processData scenarios', async () => {
       // Create pages with and without operations
       // Create pages with and without operations
       await Page.create({
       await Page.create({
         path: '/mixed-test-1',
         path: '/mixed-test-1',
@@ -342,7 +373,11 @@ describe('page-listing store integration tests', () => {
         parent: rootPage._id,
         parent: rootPage._id,
       });
       });
 
 
-      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+      const children =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          '/',
+          testUser,
+        );
 
 
       // All results should be type-safe regardless of processData presence
       // All results should be type-safe regardless of processData presence
       children.forEach((child) => {
       children.forEach((child) => {
@@ -357,7 +392,7 @@ describe('page-listing store integration tests', () => {
   });
   });
 
 
   describe('PageQueryBuilder exec() type safety tests', () => {
   describe('PageQueryBuilder exec() type safety tests', () => {
-    test('findRootByViewer should return object with correct _id type', async() => {
+    test('findRootByViewer should return object with correct _id type', async () => {
       const result = await pageListingService.findRootByViewer(testUser);
       const result = await pageListingService.findRootByViewer(testUser);
 
 
       // PageQueryBuilder.exec() returns any, but we expect ObjectId-like behavior
       // PageQueryBuilder.exec() returns any, but we expect ObjectId-like behavior
@@ -367,7 +402,7 @@ describe('page-listing store integration tests', () => {
       expect(result._id.toString().length).toBe(24); // MongoDB ObjectId string length
       expect(result._id.toString().length).toBe(24); // MongoDB ObjectId string length
     });
     });
 
 
-    test('findChildrenByParentPathOrIdAndViewer should return array with correct _id types', async() => {
+    test('findChildrenByParentPathOrIdAndViewer should return array with correct _id types', async () => {
       // Create test child page first
       // Create test child page first
       await Page.create({
       await Page.create({
         path: '/test-child',
         path: '/test-child',
@@ -380,7 +415,11 @@ describe('page-listing store integration tests', () => {
         parent: rootPage._id,
         parent: rootPage._id,
       });
       });
 
 
-      const results = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+      const results =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          '/',
+          testUser,
+        );
 
 
       expect(Array.isArray(results)).toBe(true);
       expect(Array.isArray(results)).toBe(true);
       results.forEach((result) => {
       results.forEach((result) => {
@@ -391,6 +430,5 @@ describe('page-listing store integration tests', () => {
         expect(result._id.toString().length).toBe(24);
         expect(result._id.toString().length).toBe(24);
       });
       });
     });
     });
-
   });
   });
 });
 });

+ 71 - 38
apps/app/src/server/service/page-listing/page-listing.ts

@@ -3,38 +3,48 @@ import { pagePathUtils } from '@growi/core/dist/utils';
 import mongoose, { type HydratedDocument } from 'mongoose';
 import mongoose, { type HydratedDocument } from 'mongoose';
 
 
 import type { IPageForTreeItem } from '~/interfaces/page';
 import type { IPageForTreeItem } from '~/interfaces/page';
-import { PageActionType, type IPageOperationProcessInfo, type IPageOperationProcessData } from '~/interfaces/page-operation';
-import { PageQueryBuilder, type PageDocument, type PageModel } from '~/server/models/page';
+import {
+  type IPageOperationProcessData,
+  type IPageOperationProcessInfo,
+  PageActionType,
+} from '~/interfaces/page-operation';
+import {
+  type PageDocument,
+  type PageModel,
+  PageQueryBuilder,
+} from '~/server/models/page';
 import PageOperation from '~/server/models/page-operation';
 import PageOperation from '~/server/models/page-operation';
 
 
 import type { IPageOperationService } from '../page-operation';
 import type { IPageOperationService } from '../page-operation';
 
 
 const { hasSlash, generateChildrenRegExp } = pagePathUtils;
 const { hasSlash, generateChildrenRegExp } = pagePathUtils;
 
 
-
 export interface IPageListingService {
 export interface IPageListingService {
-  findRootByViewer(user: IUser): Promise<IPageForTreeItem>,
+  findRootByViewer(user: IUser): Promise<IPageForTreeItem>;
   findChildrenByParentPathOrIdAndViewer(
   findChildrenByParentPathOrIdAndViewer(
     parentPathOrId: string,
     parentPathOrId: string,
     user?: IUser,
     user?: IUser,
     showPagesRestrictedByOwner?: boolean,
     showPagesRestrictedByOwner?: boolean,
     showPagesRestrictedByGroup?: boolean,
     showPagesRestrictedByGroup?: boolean,
-  ): Promise<IPageForTreeItem[]>,
+  ): Promise<IPageForTreeItem[]>;
 }
 }
 
 
 let pageOperationService: IPageOperationService;
 let pageOperationService: IPageOperationService;
 async function getPageOperationServiceInstance(): Promise<IPageOperationService> {
 async function getPageOperationServiceInstance(): Promise<IPageOperationService> {
   if (pageOperationService == null) {
   if (pageOperationService == null) {
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    pageOperationService = await import('../page-operation').then(mod => mod.pageOperationService!);
+    pageOperationService = await import('../page-operation').then(
+      (mod) => mod.pageOperationService!,
+    );
   }
   }
   return pageOperationService;
   return pageOperationService;
 }
 }
 
 
 class PageListingService implements IPageListingService {
 class PageListingService implements IPageListingService {
-
   async findRootByViewer(user?: IUser): Promise<IPageForTreeItem> {
   async findRootByViewer(user?: IUser): Promise<IPageForTreeItem> {
-    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+      'Page',
+    );
 
 
     const builder = new PageQueryBuilder(Page.findOne({ path: '/' }));
     const builder = new PageQueryBuilder(Page.findOne({ path: '/' }));
     await builder.addViewerCondition(user);
     await builder.addViewerCondition(user);
@@ -46,38 +56,56 @@ class PageListingService implements IPageListingService {
   }
   }
 
 
   async findChildrenByParentPathOrIdAndViewer(
   async findChildrenByParentPathOrIdAndViewer(
-      parentPathOrId: string,
-      user?: IUser,
-      showPagesRestrictedByOwner = false,
-      showPagesRestrictedByGroup = false,
+    parentPathOrId: string,
+    user?: IUser,
+    showPagesRestrictedByOwner = false,
+    showPagesRestrictedByGroup = false,
   ): Promise<IPageForTreeItem[]> {
   ): Promise<IPageForTreeItem[]> {
-    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+      'Page',
+    );
     let queryBuilder: PageQueryBuilder;
     let queryBuilder: PageQueryBuilder;
     if (hasSlash(parentPathOrId)) {
     if (hasSlash(parentPathOrId)) {
       const path = parentPathOrId;
       const path = parentPathOrId;
       const regexp = generateChildrenRegExp(path);
       const regexp = generateChildrenRegExp(path);
-      queryBuilder = new PageQueryBuilder(Page.find({ path: { $regex: regexp } }), true);
-    }
-    else {
+      queryBuilder = new PageQueryBuilder(
+        Page.find({ path: { $regex: regexp } }),
+        true,
+      );
+    } else {
       const parentId = parentPathOrId;
       const parentId = parentPathOrId;
       // Use $eq for user-controlled sources. see: https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/#recommendation
       // Use $eq for user-controlled sources. see: https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/#recommendation
-      queryBuilder = new PageQueryBuilder(Page.find({ parent: { $eq: parentId } }), true);
+      queryBuilder = new PageQueryBuilder(
+        Page.find({ parent: { $eq: parentId } }),
+        true,
+      );
     }
     }
-    await queryBuilder.addViewerCondition(user, null, undefined, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
-
-    const pages: HydratedDocument<Omit<IPageForTreeItem, 'processData'>>[] = await queryBuilder
-      .addConditionToSortPagesByAscPath()
-      .query
-      .select('_id path parent revision descendantCount grant isEmpty wip')
-      .lean()
-      .exec();
-
-    const injectedPages = await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
+    await queryBuilder.addViewerCondition(
+      user,
+      null,
+      undefined,
+      showPagesRestrictedByOwner,
+      showPagesRestrictedByGroup,
+    );
+
+    const pages: HydratedDocument<Omit<IPageForTreeItem, 'processData'>>[] =
+      await queryBuilder
+        .addConditionToSortPagesByAscPath()
+        .query.select(
+          '_id path parent revision descendantCount grant isEmpty wip',
+        )
+        .lean()
+        .exec();
+
+    const injectedPages = await this.injectProcessDataIntoPagesByActionTypes(
+      pages,
+      [PageActionType.Rename],
+    );
 
 
     // Type-safe conversion to IPageForTreeItem
     // Type-safe conversion to IPageForTreeItem
-    return injectedPages.map(page => (
-      Object.assign(page, { _id: page._id.toString() })
-    ));
+    return injectedPages.map((page) =>
+      Object.assign(page, { _id: page._id.toString() }),
+    );
   }
   }
 
 
   /**
   /**
@@ -85,17 +113,23 @@ class PageListingService implements IPageListingService {
    * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
    * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
    */
    */
   private async injectProcessDataIntoPagesByActionTypes<T>(
   private async injectProcessDataIntoPagesByActionTypes<T>(
-      pages: HydratedDocument<T>[],
-      actionTypes: PageActionType[],
-  ): Promise<(HydratedDocument<T> & { processData?: IPageOperationProcessData })[]> {
-
-    const pageOperations = await PageOperation.find({ actionType: { $in: actionTypes } });
+    pages: HydratedDocument<T>[],
+    actionTypes: PageActionType[],
+  ): Promise<
+    (HydratedDocument<T> & { processData?: IPageOperationProcessData })[]
+  > {
+    const pageOperations = await PageOperation.find({
+      actionType: { $in: actionTypes },
+    });
     if (pageOperations == null || pageOperations.length === 0) {
     if (pageOperations == null || pageOperations.length === 0) {
-      return pages.map(page => Object.assign(page, { processData: undefined }));
+      return pages.map((page) =>
+        Object.assign(page, { processData: undefined }),
+      );
     }
     }
 
 
     const pageOperationService = await getPageOperationServiceInstance();
     const pageOperationService = await getPageOperationServiceInstance();
-    const processInfo: IPageOperationProcessInfo = pageOperationService.generateProcessInfo(pageOperations);
+    const processInfo: IPageOperationProcessInfo =
+      pageOperationService.generateProcessInfo(pageOperations);
     const operatingPageIds: string[] = Object.keys(processInfo);
     const operatingPageIds: string[] = Object.keys(processInfo);
 
 
     // inject processData into pages
     // inject processData into pages
@@ -108,7 +142,6 @@ class PageListingService implements IPageListingService {
       return Object.assign(page, { processData: undefined });
       return Object.assign(page, { processData: undefined });
     });
     });
   }
   }
-
 }
 }
 
 
 export const pageListingService = new PageListingService();
 export const pageListingService = new PageListingService();

+ 97 - 40
apps/app/src/server/service/page-operation.ts

@@ -2,8 +2,11 @@ import type { IPage } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
-import type { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
-import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
+import type {
+  IPageOperationProcessData,
+  IPageOperationProcessInfo,
+} from '~/interfaces/page-operation';
+import { PageActionStage, PageActionType } from '~/interfaces/page-operation';
 import type { PageOperationDocument } from '~/server/models/page-operation';
 import type { PageOperationDocument } from '~/server/models/page-operation';
 import PageOperation from '~/server/models/page-operation';
 import PageOperation from '~/server/models/page-operation';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -15,26 +18,35 @@ import { collectAncestorPaths } from '../util/collect-ancestor-paths';
 
 
 const logger = loggerFactory('growi:services:page-operation');
 const logger = loggerFactory('growi:services:page-operation');
 
 
-const {
-  isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage,
-} = pagePathUtils;
+const { isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage } =
+  pagePathUtils;
 const AUTO_UPDATE_INTERVAL_SEC = 5;
 const AUTO_UPDATE_INTERVAL_SEC = 5;
 
 
 const {
 const {
-  Create, Update,
-  Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent,
+  Create,
+  Update,
+  Duplicate,
+  Delete,
+  DeleteCompletely,
+  Revert,
+  NormalizeParent,
 } = PageActionType;
 } = PageActionType;
 
 
 export interface IPageOperationService {
 export interface IPageOperationService {
-  generateProcessInfo(pageOperations: PageOperationDocument[]): IPageOperationProcessInfo;
-  canOperate(isRecursively: boolean, fromPathToOp: string | null, toPathToOp: string | null): Promise<boolean>;
+  generateProcessInfo(
+    pageOperations: PageOperationDocument[],
+  ): IPageOperationProcessInfo;
+  canOperate(
+    isRecursively: boolean,
+    fromPathToOp: string | null,
+    toPathToOp: string | null,
+  ): Promise<boolean>;
   autoUpdateExpiryDate(operationId: ObjectIdLike): NodeJS.Timeout;
   autoUpdateExpiryDate(operationId: ObjectIdLike): NodeJS.Timeout;
   clearAutoUpdateInterval(timerObj: NodeJS.Timeout): void;
   clearAutoUpdateInterval(timerObj: NodeJS.Timeout): void;
   getAncestorsPathsByFromAndToPath(fromPath: string, toPath: string): string[];
   getAncestorsPathsByFromAndToPath(fromPath: string, toPath: string): string[];
 }
 }
 
 
 class PageOperationService implements IPageOperationService {
 class PageOperationService implements IPageOperationService {
-
   crowi: Crowi;
   crowi: Crowi;
 
 
   constructor(crowi: Crowi) {
   constructor(crowi: Crowi) {
@@ -43,9 +55,20 @@ class PageOperationService implements IPageOperationService {
 
 
   async init(): Promise<void> {
   async init(): Promise<void> {
     // cleanup PageOperation documents except ones with { actionType: Rename, actionStage: Sub }
     // cleanup PageOperation documents except ones with { actionType: Rename, actionStage: Sub }
-    const types = [Create, Update, Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
+    const types = [
+      Create,
+      Update,
+      Duplicate,
+      Delete,
+      DeleteCompletely,
+      Revert,
+      NormalizeParent,
+    ];
     await PageOperation.deleteByActionTypes(types);
     await PageOperation.deleteByActionTypes(types);
-    await PageOperation.deleteMany({ actionType: PageActionType.Rename, actionStage: PageActionStage.Main });
+    await PageOperation.deleteMany({
+      actionType: PageActionType.Rename,
+      actionStage: PageActionStage.Main,
+    });
   }
   }
 
 
   /**
   /**
@@ -53,12 +76,13 @@ class PageOperationService implements IPageOperationService {
    */
    */
   async afterExpressServerReady(): Promise<void> {
   async afterExpressServerReady(): Promise<void> {
     try {
     try {
-      const pageOps = await PageOperation.find({ actionType: PageActionType.Rename, actionStage: PageActionStage.Sub })
-        .sort({ createdAt: 'asc' });
+      const pageOps = await PageOperation.find({
+        actionType: PageActionType.Rename,
+        actionStage: PageActionStage.Sub,
+      }).sort({ createdAt: 'asc' });
       // execute rename operation
       // execute rename operation
       await this.executeAllRenameOperationBySystem(pageOps);
       await this.executeAllRenameOperationBySystem(pageOps);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
     }
     }
   }
   }
@@ -66,13 +90,14 @@ class PageOperationService implements IPageOperationService {
   /**
   /**
    * Execute renameSubOperation on every page operation for rename ordered by createdAt ASC
    * Execute renameSubOperation on every page operation for rename ordered by createdAt ASC
    */
    */
-  private async executeAllRenameOperationBySystem(pageOps: PageOperationDocument[]): Promise<void> {
+  private async executeAllRenameOperationBySystem(
+    pageOps: PageOperationDocument[],
+  ): Promise<void> {
     if (pageOps.length === 0) return;
     if (pageOps.length === 0) return;
 
 
     const Page = mongoose.model<IPage, PageModel>('Page');
     const Page = mongoose.model<IPage, PageModel>('Page');
 
 
     for await (const pageOp of pageOps) {
     for await (const pageOp of pageOps) {
-
       const renamedPage = await Page.findById(pageOp.page._id);
       const renamedPage = await Page.findById(pageOp.page._id);
       if (renamedPage == null) {
       if (renamedPage == null) {
         logger.warn('operating page is not found');
         logger.warn('operating page is not found');
@@ -80,7 +105,10 @@ class PageOperationService implements IPageOperationService {
       }
       }
 
 
       // rename
       // rename
-      await this.crowi.pageService.resumeRenameSubOperation(renamedPage, pageOp);
+      await this.crowi.pageService.resumeRenameSubOperation(
+        renamedPage,
+        pageOp,
+      );
     }
     }
   }
   }
 
 
@@ -91,48 +119,64 @@ class PageOperationService implements IPageOperationService {
    * @param toPathToOp The path to operate to
    * @param toPathToOp The path to operate to
    * @returns boolean
    * @returns boolean
    */
    */
-  async canOperate(isRecursively: boolean, fromPathToOp: string | null, toPathToOp: string | null): Promise<boolean> {
+  async canOperate(
+    isRecursively: boolean,
+    fromPathToOp: string | null,
+    toPathToOp: string | null,
+  ): Promise<boolean> {
     const pageOperations = await PageOperation.find();
     const pageOperations = await PageOperation.find();
 
 
     if (pageOperations.length === 0) {
     if (pageOperations.length === 0) {
       return true;
       return true;
     }
     }
 
 
-    const fromPaths = pageOperations.map(op => op.fromPath).filter((p): p is string => p != null);
-    const toPaths = pageOperations.map(op => op.toPath).filter((p): p is string => p != null);
+    const fromPaths = pageOperations
+      .map((op) => op.fromPath)
+      .filter((p): p is string => p != null);
+    const toPaths = pageOperations
+      .map((op) => op.toPath)
+      .filter((p): p is string => p != null);
 
 
     if (isRecursively) {
     if (isRecursively) {
       if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
       if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
-        const fromFlag = fromPaths.some(p => isEitherOfPathAreaOverlap(p, fromPathToOp));
+        const fromFlag = fromPaths.some((p) =>
+          isEitherOfPathAreaOverlap(p, fromPathToOp),
+        );
         if (fromFlag) return false;
         if (fromFlag) return false;
 
 
-        const toFlag = toPaths.some(p => isEitherOfPathAreaOverlap(p, fromPathToOp));
+        const toFlag = toPaths.some((p) =>
+          isEitherOfPathAreaOverlap(p, fromPathToOp),
+        );
         if (toFlag) return false;
         if (toFlag) return false;
       }
       }
 
 
       if (toPathToOp != null && !isTrashPage(toPathToOp)) {
       if (toPathToOp != null && !isTrashPage(toPathToOp)) {
-        const fromFlag = fromPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        const fromFlag = fromPaths.some((p) =>
+          isPathAreaOverlap(p, toPathToOp),
+        );
         if (fromFlag) return false;
         if (fromFlag) return false;
 
 
-        const toFlag = toPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        const toFlag = toPaths.some((p) => isPathAreaOverlap(p, toPathToOp));
         if (toFlag) return false;
         if (toFlag) return false;
       }
       }
-
-    }
-    else {
+    } else {
       if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
       if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
-        const fromFlag = fromPaths.some(p => isPathAreaOverlap(p, fromPathToOp));
+        const fromFlag = fromPaths.some((p) =>
+          isPathAreaOverlap(p, fromPathToOp),
+        );
         if (fromFlag) return false;
         if (fromFlag) return false;
 
 
-        const toFlag = toPaths.some(p => isPathAreaOverlap(p, fromPathToOp));
+        const toFlag = toPaths.some((p) => isPathAreaOverlap(p, fromPathToOp));
         if (toFlag) return false;
         if (toFlag) return false;
       }
       }
 
 
       if (toPathToOp != null && !isTrashPage(toPathToOp)) {
       if (toPathToOp != null && !isTrashPage(toPathToOp)) {
-        const fromFlag = fromPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        const fromFlag = fromPaths.some((p) =>
+          isPathAreaOverlap(p, toPathToOp),
+        );
         if (fromFlag) return false;
         if (fromFlag) return false;
 
 
-        const toFlag = toPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        const toFlag = toPaths.some((p) => isPathAreaOverlap(p, toPathToOp));
         if (toFlag) return false;
         if (toFlag) return false;
       }
       }
     }
     }
@@ -144,7 +188,9 @@ class PageOperationService implements IPageOperationService {
    * Generate object that connects page id with processData of PageOperation.
    * Generate object that connects page id with processData of PageOperation.
    * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
    * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
    */
    */
-  generateProcessInfo(pageOps: PageOperationDocument[]): IPageOperationProcessInfo {
+  generateProcessInfo(
+    pageOps: PageOperationDocument[],
+  ): IPageOperationProcessInfo {
     const processInfo: IPageOperationProcessInfo = {};
     const processInfo: IPageOperationProcessInfo = {};
 
 
     pageOps.forEach((pageOp) => {
     pageOps.forEach((pageOp) => {
@@ -154,8 +200,14 @@ class PageOperationService implements IPageOperationService {
       const isProcessable = pageOp.isProcessable();
       const isProcessable = pageOp.isProcessable();
 
 
       // processData for processInfo
       // processData for processInfo
-      const mainProcessableInfo = pageOp.actionStage === PageActionStage.Main ? { isProcessable } : undefined;
-      const subProcessableInfo = pageOp.actionStage === PageActionStage.Sub ? { isProcessable } : undefined;
+      const mainProcessableInfo =
+        pageOp.actionStage === PageActionStage.Main
+          ? { isProcessable }
+          : undefined;
+      const subProcessableInfo =
+        pageOp.actionStage === PageActionStage.Sub
+          ? { isProcessable }
+          : undefined;
       const processData: IPageOperationProcessData = {
       const processData: IPageOperationProcessData = {
         [actionType]: {
         [actionType]: {
           [PageActionStage.Main]: mainProcessableInfo,
           [PageActionStage.Main]: mainProcessableInfo,
@@ -182,7 +234,7 @@ class PageOperationService implements IPageOperationService {
    */
    */
   autoUpdateExpiryDate(operationId: ObjectIdLike): NodeJS.Timeout {
   autoUpdateExpiryDate(operationId: ObjectIdLike): NodeJS.Timeout {
     // https://github.com/Microsoft/TypeScript/issues/30128#issuecomment-651877225
     // https://github.com/Microsoft/TypeScript/issues/30128#issuecomment-651877225
-    const timerObj = global.setInterval(async() => {
+    const timerObj = global.setInterval(async () => {
       await PageOperation.extendExpiryDate(operationId);
       await PageOperation.extendExpiryDate(operationId);
     }, AUTO_UPDATE_INTERVAL_SEC * 1000);
     }, AUTO_UPDATE_INTERVAL_SEC * 1000);
     return timerObj;
     return timerObj;
@@ -202,11 +254,16 @@ class PageOperationService implements IPageOperationService {
     return Array.from(new Set(toAncestorsPaths.concat(fromAncestorsPaths)));
     return Array.from(new Set(toAncestorsPaths.concat(fromAncestorsPaths)));
   }
   }
 
 
-  async getRenameSubOperationByPageId(pageId: ObjectIdLike): Promise<PageOperationDocument | null> {
-    const filter = { actionType: PageActionType.Rename, actionStage: PageActionStage.Sub, 'page._id': pageId };
+  async getRenameSubOperationByPageId(
+    pageId: ObjectIdLike,
+  ): Promise<PageOperationDocument | null> {
+    const filter = {
+      actionType: PageActionType.Rename,
+      actionStage: PageActionStage.Sub,
+      'page._id': pageId,
+    };
     return PageOperation.findOne(filter);
     return PageOperation.findOne(filter);
   }
   }
-
 }
 }
 
 
 // eslint-disable-next-line import/no-mutable-exports
 // eslint-disable-next-line import/no-mutable-exports

+ 60 - 56
apps/app/src/server/service/passport.spec.ts

@@ -8,10 +8,9 @@ import { configManager } from './config-manager';
 import PassportService from './passport';
 import PassportService from './passport';
 
 
 describe('PassportService test', () => {
 describe('PassportService test', () => {
+  let crowiMock: Crowi;
 
 
-  let crowiMock;
-
-  beforeAll(async() => {
+  beforeAll(async () => {
     crowiMock = mock<Crowi>({
     crowiMock = mock<Crowi>({
       event: vi.fn().mockImplementation((eventName) => {
       event: vi.fn().mockImplementation((eventName) => {
         if (eventName === 'user') {
         if (eventName === 'user') {
@@ -24,71 +23,76 @@ describe('PassportService test', () => {
   });
   });
 
 
   describe('verifySAMLResponseByABLCRule()', () => {
   describe('verifySAMLResponseByABLCRule()', () => {
-
     const passportService = new PassportService(crowiMock);
     const passportService = new PassportService(crowiMock);
 
 
     let getConfigSpy: MockInstance<typeof configManager.getConfig>;
     let getConfigSpy: MockInstance<typeof configManager.getConfig>;
-    let extractAttributesFromSAMLResponseSpy: MockInstance<typeof passportService.extractAttributesFromSAMLResponse>;
+    let extractAttributesFromSAMLResponseSpy: MockInstance<
+      typeof passportService.extractAttributesFromSAMLResponse
+    >;
 
 
-    beforeEach(async() => {
+    beforeEach(async () => {
       // prepare spy for ConfigManager.getConfig
       // prepare spy for ConfigManager.getConfig
       getConfigSpy = vi.spyOn(configManager, 'getConfig');
       getConfigSpy = vi.spyOn(configManager, 'getConfig');
       // prepare spy for extractAttributesFromSAMLResponse method
       // prepare spy for extractAttributesFromSAMLResponse method
-      extractAttributesFromSAMLResponseSpy = vi.spyOn(passportService, 'extractAttributesFromSAMLResponse');
+      extractAttributesFromSAMLResponseSpy = vi.spyOn(
+        passportService,
+        'extractAttributesFromSAMLResponse',
+      );
     });
     });
 
 
     /* eslint-disable indent */
     /* eslint-disable indent */
     let i = 0;
     let i = 0;
     describe.each`
     describe.each`
-      conditionId | departments   | positions     | ruleStr                                                         | expected
-      ${i++}      | ${undefined}  | ${undefined}  | ${' '}                                                          | ${true}
-      ${i++}      | ${undefined}  | ${undefined}  | ${'Department: A'}                                              | ${false}
-      ${i++}      | ${[]}         | ${['Leader']} | ${'Position'}                                                   | ${true}
-      ${i++}      | ${[]}         | ${['Leader']} | ${'Position: Leader'}                                           | ${true}
-      ${i++}      | ${['A']}      | ${[]}         | ${'Department: A || Department: B && Position: Leader'}         | ${true}
-      ${i++}      | ${['B']}      | ${['Leader']} | ${'Department: A || Department: B && Position: Leader'}         | ${true}
-      ${i++}      | ${['A', 'C']} | ${['Leader']} | ${'Department: A || Department: B && Position: Leader'}         | ${true}
-      ${i++}      | ${['B', 'C']} | ${['Leader']} | ${'Department: A || Department: B && Position: Leader'}         | ${true}
-      ${i++}      | ${[]}         | ${[]}         | ${'Department: A || Department: B && Position: Leader'}         | ${false}
-      ${i++}      | ${['C']}      | ${['Leader']} | ${'Department: A || Department: B && Position: Leader'}         | ${false}
-      ${i++}      | ${['A']}      | ${['Leader']} | ${'(Department: A || Department: B) && Position: Leader'}       | ${true}
-      ${i++}      | ${['B']}      | ${['Leader']} | ${'(Department: A || Department: B) && Position: Leader'}       | ${true}
-      ${i++}      | ${['C']}      | ${['Leader']} | ${'(Department: A || Department: B) && Position: Leader'}       | ${false}
-      ${i++}      | ${['A', 'B']} | ${[]}         | ${'(Department: A || Department: B) && Position: Leader'}       | ${false}
-      ${i++}      | ${['A']}      | ${[]}         | ${'Department: A NOT Position: Leader'}                         | ${true}
-      ${i++}      | ${['A']}      | ${['Leader']} | ${'Department: A NOT Position: Leader'}                         | ${false}
-      ${i++}      | ${[]}         | ${['Leader']} | ${'Department: A OR (Position NOT Position: User)'}             | ${true}
-      ${i++}      | ${[]}         | ${['User']}   | ${'Department: A OR (Position NOT Position: User)'}             | ${false}
-    `('to be $expected under rule="$ruleStr"', ({
-      conditionId, departments, positions, ruleStr, expected,
-    }) => {
-      test(`when conditionId=${conditionId}`, async() => {
-        const responseMock = {};
-
-        // setup mock implementation
-        getConfigSpy.mockImplementation((key) => {
-          if (key === 'security:passport-saml:ABLCRule') {
-            return ruleStr;
-          }
-          throw new Error('Unexpected behavior.');
-        });
-        extractAttributesFromSAMLResponseSpy.mockImplementation((response) => {
-          if (response !== responseMock) {
-            throw new Error('Unexpected args.');
-          }
-          return {
-            Department: departments,
-            Position: positions,
-          };
+      conditionId | departments   | positions     | ruleStr                                                   | expected
+      ${i++}      | ${undefined}  | ${undefined}  | ${' '}                                                    | ${true}
+      ${i++}      | ${undefined}  | ${undefined}  | ${'Department: A'}                                        | ${false}
+      ${i++}      | ${[]}         | ${['Leader']} | ${'Position'}                                             | ${true}
+      ${i++}      | ${[]}         | ${['Leader']} | ${'Position: Leader'}                                     | ${true}
+      ${i++}      | ${['A']}      | ${[]}         | ${'Department: A || Department: B && Position: Leader'}   | ${true}
+      ${i++}      | ${['B']}      | ${['Leader']} | ${'Department: A || Department: B && Position: Leader'}   | ${true}
+      ${i++}      | ${['A', 'C']} | ${['Leader']} | ${'Department: A || Department: B && Position: Leader'}   | ${true}
+      ${i++}      | ${['B', 'C']} | ${['Leader']} | ${'Department: A || Department: B && Position: Leader'}   | ${true}
+      ${i++}      | ${[]}         | ${[]}         | ${'Department: A || Department: B && Position: Leader'}   | ${false}
+      ${i++}      | ${['C']}      | ${['Leader']} | ${'Department: A || Department: B && Position: Leader'}   | ${false}
+      ${i++}      | ${['A']}      | ${['Leader']} | ${'(Department: A || Department: B) && Position: Leader'} | ${true}
+      ${i++}      | ${['B']}      | ${['Leader']} | ${'(Department: A || Department: B) && Position: Leader'} | ${true}
+      ${i++}      | ${['C']}      | ${['Leader']} | ${'(Department: A || Department: B) && Position: Leader'} | ${false}
+      ${i++}      | ${['A', 'B']} | ${[]}         | ${'(Department: A || Department: B) && Position: Leader'} | ${false}
+      ${i++}      | ${['A']}      | ${[]}         | ${'Department: A NOT Position: Leader'}                   | ${true}
+      ${i++}      | ${['A']}      | ${['Leader']} | ${'Department: A NOT Position: Leader'}                   | ${false}
+      ${i++}      | ${[]}         | ${['Leader']} | ${'Department: A OR (Position NOT Position: User)'}       | ${true}
+      ${i++}      | ${[]}         | ${['User']}   | ${'Department: A OR (Position NOT Position: User)'}       | ${false}
+    `(
+      'to be $expected under rule="$ruleStr"',
+      ({ conditionId, departments, positions, ruleStr, expected }) => {
+        test(`when conditionId=${conditionId}`, async () => {
+          const responseMock = {};
+
+          // setup mock implementation
+          getConfigSpy.mockImplementation((key) => {
+            if (key === 'security:passport-saml:ABLCRule') {
+              return ruleStr;
+            }
+            throw new Error('Unexpected behavior.');
+          });
+          extractAttributesFromSAMLResponseSpy.mockImplementation(
+            (response) => {
+              if (response !== responseMock) {
+                throw new Error('Unexpected args.');
+              }
+              return {
+                Department: departments,
+                Position: positions,
+              };
+            },
+          );
+
+          const result =
+            passportService.verifySAMLResponseByABLCRule(responseMock);
+
+          expect(result).toBe(expected);
         });
         });
-
-        const result = passportService.verifySAMLResponseByABLCRule(responseMock);
-
-        expect(result).toBe(expected);
-      });
-    });
-
+      },
+    );
   });
   });
-
-
 });
 });

+ 344 - 174
apps/app/src/server/service/passport.ts

@@ -1,8 +1,11 @@
-import type { IncomingMessage } from 'http';
-
 import axiosRetry from 'axios-retry';
 import axiosRetry from 'axios-retry';
+import type { IncomingMessage } from 'http';
 import luceneQueryParser from 'lucene-query-parser';
 import luceneQueryParser from 'lucene-query-parser';
-import { Strategy as OidcStrategy, Issuer as OIDCIssuer, custom } from 'openid-client';
+import {
+  custom,
+  Issuer as OIDCIssuer,
+  Strategy as OidcStrategy,
+} from 'openid-client';
 import pRetry from 'p-retry';
 import pRetry from 'p-retry';
 import passport from 'passport';
 import passport from 'passport';
 import { Strategy as GitHubStrategy } from 'passport-github';
 import { Strategy as GitHubStrategy } from 'passport-github';
@@ -14,10 +17,10 @@ import { Strategy as SamlStrategy } from 'passport-saml';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
 import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
+import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import S2sMessage from '../models/vo/s2s-message';
 import S2sMessage from '../models/vo/s2s-message';
-
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 import type { ConfigKey } from './config-manager/config-definition';
 import type { ConfigKey } from './config-manager/config-definition';
 import { growiInfoService } from './growi-info';
 import { growiInfoService } from './growi-info';
@@ -25,7 +28,6 @@ import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 
 const logger = loggerFactory('growi:service:PassportService');
 const logger = loggerFactory('growi:service:PassportService');
 
 
-
 interface IncomingMessageWithLdapAccountInfo extends IncomingMessage {
 interface IncomingMessageWithLdapAccountInfo extends IncomingMessage {
   ldapAccountInfo: any;
   ldapAccountInfo: any;
 }
 }
@@ -34,11 +36,14 @@ interface IncomingMessageWithLdapAccountInfo extends IncomingMessage {
  * the service class of Passport
  * the service class of Passport
  */
  */
 class PassportService implements S2sMessageHandlable {
 class PassportService implements S2sMessageHandlable {
-
   // see '/lib/form/login.js'
   // see '/lib/form/login.js'
-  static get USERNAME_FIELD() { return 'loginForm[username]' }
+  static get USERNAME_FIELD() {
+    return 'loginForm[username]';
+  }
 
 
-  static get PASSWORD_FIELD() { return 'loginForm[password]' }
+  static get PASSWORD_FIELD() {
+    return 'loginForm[password]';
+  }
 
 
   crowi!: any;
   crowi!: any;
 
 
@@ -122,17 +127,23 @@ class PassportService implements S2sMessageHandlable {
     this.crowi = crowi;
     this.crowi = crowi;
   }
   }
 
 
-
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
   shouldHandleS2sMessage(s2sMessage) {
   shouldHandleS2sMessage(s2sMessage) {
     const { eventName, updatedAt, strategyId } = s2sMessage;
     const { eventName, updatedAt, strategyId } = s2sMessage;
-    if (eventName !== 'passportServiceUpdated' || updatedAt == null || strategyId == null) {
+    if (
+      eventName !== 'passportServiceUpdated' ||
+      updatedAt == null ||
+      strategyId == null
+    ) {
       return false;
       return false;
     }
     }
 
 
-    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
+    return (
+      this.lastLoadedAt == null ||
+      this.lastLoadedAt < new Date(s2sMessage.updatedAt)
+    );
   }
   }
 
 
   /**
   /**
@@ -158,9 +169,11 @@ class PassportService implements S2sMessageHandlable {
 
 
       try {
       try {
         await s2sMessagingService.publish(s2sMessage);
         await s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      } catch (e) {
+        logger.error(
+          'Failed to publish update message with S2sMessagingService: ',
+          e.message,
+        );
       }
       }
     }
     }
   }
   }
@@ -174,12 +187,24 @@ class PassportService implements S2sMessageHandlable {
   getSetupStrategies() {
   getSetupStrategies() {
     const setupStrategies: string[] = [];
     const setupStrategies: string[] = [];
 
 
-    if (this.isLocalStrategySetup) { setupStrategies.push('local') }
-    if (this.isLdapStrategySetup) { setupStrategies.push('ldap') }
-    if (this.isSamlStrategySetup) { setupStrategies.push('saml') }
-    if (this.isOidcStrategySetup) { setupStrategies.push('oidc') }
-    if (this.isGoogleStrategySetup) { setupStrategies.push('google') }
-    if (this.isGitHubStrategySetup) { setupStrategies.push('github') }
+    if (this.isLocalStrategySetup) {
+      setupStrategies.push('local');
+    }
+    if (this.isLdapStrategySetup) {
+      setupStrategies.push('ldap');
+    }
+    if (this.isSamlStrategySetup) {
+      setupStrategies.push('saml');
+    }
+    if (this.isOidcStrategySetup) {
+      setupStrategies.push('oidc');
+    }
+    if (this.isGoogleStrategySetup) {
+      setupStrategies.push('google');
+    }
+    if (this.isGitHubStrategySetup) {
+      setupStrategies.push('github');
+    }
 
 
     return setupStrategies;
     return setupStrategies;
   }
   }
@@ -202,8 +227,7 @@ class PassportService implements S2sMessageHandlable {
 
 
     try {
     try {
       await this[func.setup]();
       await this[func.setup]();
-    }
-    catch (err) {
+    } catch (err) {
       logger.debug(err);
       logger.debug(err);
       this[func.reset]();
       this[func.reset]();
     }
     }
@@ -228,12 +252,13 @@ class PassportService implements S2sMessageHandlable {
    * @memberof PassportService
    * @memberof PassportService
    */
    */
   setupLocalStrategy() {
   setupLocalStrategy() {
-
     this.resetLocalStrategy();
     this.resetLocalStrategy();
 
 
     const { configManager } = this.crowi;
     const { configManager } = this.crowi;
 
 
-    const isEnabled = configManager.getConfig('security:passport-local:isEnabled');
+    const isEnabled = configManager.getConfig(
+      'security:passport-local:isEnabled',
+    );
 
 
     // when disabled
     // when disabled
     if (!isEnabled) {
     if (!isEnabled) {
@@ -244,23 +269,27 @@ class PassportService implements S2sMessageHandlable {
 
 
     const User = this.crowi.model('User');
     const User = this.crowi.model('User');
 
 
-    passport.use(new LocalStrategy(
-      {
-        usernameField: PassportService.USERNAME_FIELD,
-        passwordField: PassportService.PASSWORD_FIELD,
-      },
-      (username, password, done) => {
-        // find user
-        User.findUserByUsernameOrEmail(username, password, (err, user) => {
-          if (err) { return done(err) }
-          // check existence and password
-          if (!user || !user.isPasswordValid(password)) {
-            return done(null, false, { message: 'Incorrect credentials.' });
-          }
-          return done(null, user);
-        });
-      },
-    ));
+    passport.use(
+      new LocalStrategy(
+        {
+          usernameField: PassportService.USERNAME_FIELD,
+          passwordField: PassportService.PASSWORD_FIELD,
+        },
+        (username, password, done) => {
+          // find user
+          User.findUserByUsernameOrEmail(username, password, (err, user) => {
+            if (err) {
+              return done(err);
+            }
+            // check existence and password
+            if (!user || !user.isPasswordValid(password)) {
+              return done(null, false, { message: 'Incorrect credentials.' });
+            }
+            return done(null, user);
+          });
+        },
+      ),
+    );
 
 
     this.isLocalStrategySetup = true;
     this.isLocalStrategySetup = true;
     logger.debug('LocalStrategy: setup is done');
     logger.debug('LocalStrategy: setup is done');
@@ -283,13 +312,14 @@ class PassportService implements S2sMessageHandlable {
    * @memberof PassportService
    * @memberof PassportService
    */
    */
   setupLdapStrategy() {
   setupLdapStrategy() {
-
     this.resetLdapStrategy();
     this.resetLdapStrategy();
 
 
     const config = this.crowi.config;
     const config = this.crowi.config;
     const { configManager } = this.crowi;
     const { configManager } = this.crowi;
 
 
-    const isLdapEnabled = configManager.getConfig('security:passport-ldap:isEnabled');
+    const isLdapEnabled = configManager.getConfig(
+      'security:passport-ldap:isEnabled',
+    );
 
 
     // when disabled
     // when disabled
     if (!isLdapEnabled) {
     if (!isLdapEnabled) {
@@ -298,15 +328,20 @@ class PassportService implements S2sMessageHandlable {
 
 
     logger.debug('LdapStrategy: setting up..');
     logger.debug('LdapStrategy: setting up..');
 
 
-    passport.use(new LdapStrategy(this.getLdapConfigurationFunc(config, { passReqToCallback: true }),
-      (req, ldapAccountInfo, done) => {
-        logger.debug('LDAP authentication has succeeded', ldapAccountInfo);
+    passport.use(
+      new LdapStrategy(
+        this.getLdapConfigurationFunc(config, { passReqToCallback: true }),
+        (req, ldapAccountInfo, done) => {
+          logger.debug('LDAP authentication has succeeded', ldapAccountInfo);
 
 
-        // store ldapAccountInfo to req
-        (req as IncomingMessageWithLdapAccountInfo).ldapAccountInfo = ldapAccountInfo;
+          // store ldapAccountInfo to req
+          (req as IncomingMessageWithLdapAccountInfo).ldapAccountInfo =
+            ldapAccountInfo;
 
 
-        done(null, ldapAccountInfo);
-      }));
+          done(null, ldapAccountInfo);
+        },
+      ),
+    );
 
 
     this.isLdapStrategySetup = true;
     this.isLdapStrategySetup = true;
     logger.debug('LdapStrategy: setup is done');
     logger.debug('LdapStrategy: setup is done');
@@ -319,7 +354,9 @@ class PassportService implements S2sMessageHandlable {
    * @memberof PassportService
    * @memberof PassportService
    */
    */
   getLdapAttrNameMappedToUsername() {
   getLdapAttrNameMappedToUsername() {
-    return configManager.getConfig('security:passport-ldap:attrMapUsername') || 'uid';
+    return (
+      configManager.getConfig('security:passport-ldap:attrMapUsername') || 'uid'
+    );
   }
   }
 
 
   /**
   /**
@@ -339,7 +376,9 @@ class PassportService implements S2sMessageHandlable {
    * @memberof PassportService
    * @memberof PassportService
    */
    */
   getLdapAttrNameMappedToMail() {
   getLdapAttrNameMappedToMail() {
-    return configManager.getConfig('security:passport-ldap:attrMapMail') || 'mail';
+    return (
+      configManager.getConfig('security:passport-ldap:attrMapMail') || 'mail'
+    );
   }
   }
 
 
   /**
   /**
@@ -367,14 +406,28 @@ class PassportService implements S2sMessageHandlable {
     const { configManager } = this.crowi;
     const { configManager } = this.crowi;
 
 
     // get configurations
     // get configurations
-    const isUserBind        = configManager.getConfig('security:passport-ldap:isUserBind');
-    const serverUrl         = configManager.getConfig('security:passport-ldap:serverUrl');
-    const bindDN            = configManager.getConfig('security:passport-ldap:bindDN');
-    const bindCredentials   = configManager.getConfig('security:passport-ldap:bindDNPassword');
-    const searchFilter      = configManager.getConfig('security:passport-ldap:searchFilter') || '(uid={{username}})';
-    const groupSearchBase   = configManager.getConfig('security:passport-ldap:groupSearchBase');
-    const groupSearchFilter = configManager.getConfig('security:passport-ldap:groupSearchFilter');
-    const groupDnProperty   = configManager.getConfig('security:passport-ldap:groupDnProperty') || 'uid';
+    const isUserBind = configManager.getConfig(
+      'security:passport-ldap:isUserBind',
+    );
+    const serverUrl = configManager.getConfig(
+      'security:passport-ldap:serverUrl',
+    );
+    const bindDN = configManager.getConfig('security:passport-ldap:bindDN');
+    const bindCredentials = configManager.getConfig(
+      'security:passport-ldap:bindDNPassword',
+    );
+    const searchFilter =
+      configManager.getConfig('security:passport-ldap:searchFilter') ||
+      '(uid={{username}})';
+    const groupSearchBase = configManager.getConfig(
+      'security:passport-ldap:groupSearchBase',
+    );
+    const groupSearchFilter = configManager.getConfig(
+      'security:passport-ldap:groupSearchFilter',
+    );
+    const groupDnProperty =
+      configManager.getConfig('security:passport-ldap:groupDnProperty') ||
+      'uid';
     /* eslint-enable no-multi-spaces */
     /* eslint-enable no-multi-spaces */
 
 
     // parse serverUrl
     // parse serverUrl
@@ -382,7 +435,9 @@ class PassportService implements S2sMessageHandlable {
     const match = serverUrl.match(/(ldaps?:\/\/[^/]+)\/(.*)?/);
     const match = serverUrl.match(/(ldaps?:\/\/[^/]+)\/(.*)?/);
     if (match == null || match.length < 1) {
     if (match == null || match.length < 1) {
       logger.debug('LdapStrategy: serverUrl is invalid');
       logger.debug('LdapStrategy: serverUrl is invalid');
-      return (req, callback) => { callback({ message: 'serverUrl is invalid' }) };
+      return (req, callback) => {
+        callback({ message: 'serverUrl is invalid' });
+      };
     }
     }
     const url = match[1];
     const url = match[1];
     const searchBase = match[2] || '';
     const searchBase = match[2] || '';
@@ -407,10 +462,12 @@ class PassportService implements S2sMessageHandlable {
       }
       }
 
 
       // user bind
       // user bind
-      const fixedBindDN = (isUserBind)
+      const fixedBindDN = isUserBind
         ? bindDN.replace(/{{username}}/, loginForm.username)
         ? bindDN.replace(/{{username}}/, loginForm.username)
         : bindDN;
         : bindDN;
-      const fixedBindCredentials = (isUserBind) ? loginForm.password : bindCredentials;
+      const fixedBindCredentials = isUserBind
+        ? loginForm.password
+        : bindCredentials;
       let serverOpt = {
       let serverOpt = {
         url,
         url,
         bindDN: fixedBindDN,
         bindDN: fixedBindDN,
@@ -422,15 +479,22 @@ class PassportService implements S2sMessageHandlable {
       };
       };
 
 
       if (groupSearchBase && groupSearchFilter) {
       if (groupSearchBase && groupSearchFilter) {
-        serverOpt = Object.assign(serverOpt, { groupSearchBase, groupSearchFilter, groupDnProperty });
+        serverOpt = Object.assign(serverOpt, {
+          groupSearchBase,
+          groupSearchFilter,
+          groupDnProperty,
+        });
       }
       }
 
 
       process.nextTick(() => {
       process.nextTick(() => {
-        const mergedOpts = Object.assign({
-          usernameField: PassportService.USERNAME_FIELD,
-          passwordField: PassportService.PASSWORD_FIELD,
-          server: serverOpt,
-        }, opts);
+        const mergedOpts = Object.assign(
+          {
+            usernameField: PassportService.USERNAME_FIELD,
+            passwordField: PassportService.PASSWORD_FIELD,
+            server: serverOpt,
+          },
+          opts,
+        );
         logger.debug('ldap configuration: ', mergedOpts);
         logger.debug('ldap configuration: ', mergedOpts);
 
 
         // store configuration to req
         // store configuration to req
@@ -447,10 +511,11 @@ class PassportService implements S2sMessageHandlable {
    * @memberof PassportService
    * @memberof PassportService
    */
    */
   setupGoogleStrategy() {
   setupGoogleStrategy() {
-
     this.resetGoogleStrategy();
     this.resetGoogleStrategy();
 
 
-    const isGoogleEnabled = configManager.getConfig('security:passport-google:isEnabled');
+    const isGoogleEnabled = configManager.getConfig(
+      'security:passport-google:isEnabled',
+    );
 
 
     // when disabled
     // when disabled
     if (!isGoogleEnabled) {
     if (!isGoogleEnabled) {
@@ -461,11 +526,21 @@ class PassportService implements S2sMessageHandlable {
     passport.use(
     passport.use(
       new GoogleStrategy(
       new GoogleStrategy(
         {
         {
-          clientID: configManager.getConfig('security:passport-google:clientId'),
-          clientSecret: configManager.getConfig('security:passport-google:clientSecret'),
-          callbackURL: configManager.getConfig('app:siteUrl') != null
-            ? urljoin(growiInfoService.getSiteUrl(), '/passport/google/callback') // auto-generated with v3.2.4 and above
-            : configManager.getConfigLegacy<string>('security:passport-google:callbackUrl'), // DEPRECATED: backward compatible with v3.2.3 and below
+          clientID: configManager.getConfig(
+            'security:passport-google:clientId',
+          ),
+          clientSecret: configManager.getConfig(
+            'security:passport-google:clientSecret',
+          ),
+          callbackURL:
+            configManager.getConfig('app:siteUrl') != null
+              ? urljoin(
+                  growiInfoService.getSiteUrl(),
+                  '/passport/google/callback',
+                ) // auto-generated with v3.2.4 and above
+              : configManager.getConfigLegacy<string>(
+                  'security:passport-google:callbackUrl',
+                ), // DEPRECATED: backward compatible with v3.2.3 and below
           skipUserProfile: false,
           skipUserProfile: false,
         },
         },
         (accessToken, refreshToken, profile, done) => {
         (accessToken, refreshToken, profile, done) => {
@@ -494,10 +569,11 @@ class PassportService implements S2sMessageHandlable {
   }
   }
 
 
   setupGitHubStrategy() {
   setupGitHubStrategy() {
-
     this.resetGitHubStrategy();
     this.resetGitHubStrategy();
 
 
-    const isGitHubEnabled = configManager.getConfig('security:passport-github:isEnabled');
+    const isGitHubEnabled = configManager.getConfig(
+      'security:passport-github:isEnabled',
+    );
 
 
     // when disabled
     // when disabled
     if (!isGitHubEnabled) {
     if (!isGitHubEnabled) {
@@ -508,11 +584,21 @@ class PassportService implements S2sMessageHandlable {
     passport.use(
     passport.use(
       new GitHubStrategy(
       new GitHubStrategy(
         {
         {
-          clientID: configManager.getConfig('security:passport-github:clientId'),
-          clientSecret: configManager.getConfig('security:passport-github:clientSecret'),
-          callbackURL: configManager.getConfig('app:siteUrl') != null
-            ? urljoin(growiInfoService.getSiteUrl(), '/passport/github/callback') // auto-generated with v3.2.4 and above
-            : configManager.getConfigLegacy('security:passport-github:callbackUrl'), // DEPRECATED: backward compatible with v3.2.3 and below
+          clientID: configManager.getConfig(
+            'security:passport-github:clientId',
+          ),
+          clientSecret: configManager.getConfig(
+            'security:passport-github:clientSecret',
+          ),
+          callbackURL:
+            configManager.getConfig('app:siteUrl') != null
+              ? urljoin(
+                  growiInfoService.getSiteUrl(),
+                  '/passport/github/callback',
+                ) // auto-generated with v3.2.4 and above
+              : configManager.getConfigLegacy(
+                  'security:passport-github:callbackUrl',
+                ), // DEPRECATED: backward compatible with v3.2.3 and below
           skipUserProfile: false,
           skipUserProfile: false,
         },
         },
         (accessToken, refreshToken, profile, done) => {
         (accessToken, refreshToken, profile, done) => {
@@ -541,10 +627,11 @@ class PassportService implements S2sMessageHandlable {
   }
   }
 
 
   async setupOidcStrategy() {
   async setupOidcStrategy() {
-
     this.resetOidcStrategy();
     this.resetOidcStrategy();
 
 
-    const isOidcEnabled = configManager.getConfig('security:passport-oidc:isEnabled');
+    const isOidcEnabled = configManager.getConfig(
+      'security:passport-oidc:isEnabled',
+    );
 
 
     // when disabled
     // when disabled
     if (!isOidcEnabled) {
     if (!isOidcEnabled) {
@@ -555,52 +642,79 @@ class PassportService implements S2sMessageHandlable {
 
 
     // setup client
     // setup client
     // extend oidc request timeouts
     // extend oidc request timeouts
-    const OIDC_ISSUER_TIMEOUT_OPTION = await configManager.getConfig('security:passport-oidc:oidcIssuerTimeoutOption');
+    const OIDC_ISSUER_TIMEOUT_OPTION = await configManager.getConfig(
+      'security:passport-oidc:oidcIssuerTimeoutOption',
+    );
     // OIDCIssuer.defaultHttpOptions = { timeout: OIDC_ISSUER_TIMEOUT_OPTION };
     // OIDCIssuer.defaultHttpOptions = { timeout: OIDC_ISSUER_TIMEOUT_OPTION };
 
 
     custom.setHttpOptionsDefaults({
     custom.setHttpOptionsDefaults({
       timeout: OIDC_ISSUER_TIMEOUT_OPTION,
       timeout: OIDC_ISSUER_TIMEOUT_OPTION,
     });
     });
 
 
-    const issuerHost = configManager.getConfig('security:passport-oidc:issuerHost');
+    const issuerHost = configManager.getConfig(
+      'security:passport-oidc:issuerHost',
+    );
     const clientId = configManager.getConfig('security:passport-oidc:clientId');
     const clientId = configManager.getConfig('security:passport-oidc:clientId');
-    const clientSecret = configManager.getConfig('security:passport-oidc:clientSecret');
-    const redirectUri = configManager.getConfig('app:siteUrl') != null
-      ? urljoin(growiInfoService.getSiteUrl(), '/passport/oidc/callback')
-      : configManager.getConfigLegacy<string>('security:passport-oidc:callbackUrl'); // DEPRECATED: backward compatible with v3.2.3 and below
+    const clientSecret = configManager.getConfig(
+      'security:passport-oidc:clientSecret',
+    );
+    const redirectUri =
+      configManager.getConfig('app:siteUrl') != null
+        ? urljoin(growiInfoService.getSiteUrl(), '/passport/oidc/callback')
+        : configManager.getConfigLegacy<string>(
+            'security:passport-oidc:callbackUrl',
+          ); // DEPRECATED: backward compatible with v3.2.3 and below
 
 
     // Prevent request timeout error on app init
     // Prevent request timeout error on app init
     const oidcIssuer = await this.getOIDCIssuerInstance(issuerHost);
     const oidcIssuer = await this.getOIDCIssuerInstance(issuerHost);
     if (clientId != null && oidcIssuer != null) {
     if (clientId != null && oidcIssuer != null) {
       const oidcIssuerMetadata = oidcIssuer.metadata;
       const oidcIssuerMetadata = oidcIssuer.metadata;
 
 
-      logger.debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
+      logger.debug(
+        'Discovered issuer %s %O',
+        oidcIssuer.issuer,
+        oidcIssuer.metadata,
+      );
 
 
-      const authorizationEndpoint = configManager.getConfig('security:passport-oidc:authorizationEndpoint');
+      const authorizationEndpoint = configManager.getConfig(
+        'security:passport-oidc:authorizationEndpoint',
+      );
       if (authorizationEndpoint) {
       if (authorizationEndpoint) {
         oidcIssuerMetadata.authorization_endpoint = authorizationEndpoint;
         oidcIssuerMetadata.authorization_endpoint = authorizationEndpoint;
       }
       }
-      const tokenEndpoint = configManager.getConfig('security:passport-oidc:tokenEndpoint');
+      const tokenEndpoint = configManager.getConfig(
+        'security:passport-oidc:tokenEndpoint',
+      );
       if (tokenEndpoint) {
       if (tokenEndpoint) {
         oidcIssuerMetadata.token_endpoint = tokenEndpoint;
         oidcIssuerMetadata.token_endpoint = tokenEndpoint;
       }
       }
-      const revocationEndpoint = configManager.getConfig('security:passport-oidc:revocationEndpoint');
+      const revocationEndpoint = configManager.getConfig(
+        'security:passport-oidc:revocationEndpoint',
+      );
       if (revocationEndpoint) {
       if (revocationEndpoint) {
         oidcIssuerMetadata.revocation_endpoint = revocationEndpoint;
         oidcIssuerMetadata.revocation_endpoint = revocationEndpoint;
       }
       }
-      const introspectionEndpoint = configManager.getConfig('security:passport-oidc:introspectionEndpoint');
+      const introspectionEndpoint = configManager.getConfig(
+        'security:passport-oidc:introspectionEndpoint',
+      );
       if (introspectionEndpoint) {
       if (introspectionEndpoint) {
         oidcIssuerMetadata.introspection_endpoint = introspectionEndpoint;
         oidcIssuerMetadata.introspection_endpoint = introspectionEndpoint;
       }
       }
-      const userInfoEndpoint = configManager.getConfig('security:passport-oidc:userInfoEndpoint');
+      const userInfoEndpoint = configManager.getConfig(
+        'security:passport-oidc:userInfoEndpoint',
+      );
       if (userInfoEndpoint) {
       if (userInfoEndpoint) {
         oidcIssuerMetadata.userinfo_endpoint = userInfoEndpoint;
         oidcIssuerMetadata.userinfo_endpoint = userInfoEndpoint;
       }
       }
-      const endSessionEndpoint = configManager.getConfig('security:passport-oidc:endSessionEndpoint');
+      const endSessionEndpoint = configManager.getConfig(
+        'security:passport-oidc:endSessionEndpoint',
+      );
       if (endSessionEndpoint) {
       if (endSessionEndpoint) {
         oidcIssuerMetadata.end_session_endpoint = endSessionEndpoint;
         oidcIssuerMetadata.end_session_endpoint = endSessionEndpoint;
       }
       }
-      const registrationEndpoint = configManager.getConfig('security:passport-oidc:registrationEndpoint');
+      const registrationEndpoint = configManager.getConfig(
+        'security:passport-oidc:registrationEndpoint',
+      );
       if (registrationEndpoint) {
       if (registrationEndpoint) {
         oidcIssuerMetadata.registration_endpoint = registrationEndpoint;
         oidcIssuerMetadata.registration_endpoint = registrationEndpoint;
       }
       }
@@ -611,7 +725,11 @@ class PassportService implements S2sMessageHandlable {
 
 
       const newOidcIssuer = new OIDCIssuer(oidcIssuerMetadata);
       const newOidcIssuer = new OIDCIssuer(oidcIssuerMetadata);
 
 
-      logger.debug('Configured issuer %s %O', newOidcIssuer.issuer, newOidcIssuer.metadata);
+      logger.debug(
+        'Configured issuer %s %O',
+        newOidcIssuer.issuer,
+        newOidcIssuer.metadata,
+      );
 
 
       const client = new newOidcIssuer.Client({
       const client = new newOidcIssuer.Client({
         client_id: clientId,
         client_id: clientId,
@@ -621,26 +739,30 @@ class PassportService implements S2sMessageHandlable {
       });
       });
       // prevent error AssertionError [ERR_ASSERTION]: id_token issued in the future
       // prevent error AssertionError [ERR_ASSERTION]: id_token issued in the future
       // Doc: https://github.com/panva/node-openid-client/tree/v2.x#allow-for-system-clock-skew
       // Doc: https://github.com/panva/node-openid-client/tree/v2.x#allow-for-system-clock-skew
-      const OIDC_CLIENT_CLOCK_TOLERANCE = await configManager.getConfig('security:passport-oidc:oidcClientClockTolerance');
+      const OIDC_CLIENT_CLOCK_TOLERANCE = await configManager.getConfig(
+        'security:passport-oidc:oidcClientClockTolerance',
+      );
       client[custom.clock_tolerance] = OIDC_CLIENT_CLOCK_TOLERANCE;
       client[custom.clock_tolerance] = OIDC_CLIENT_CLOCK_TOLERANCE;
-      passport.use('oidc', new OidcStrategy(
-        {
-          client,
-          params: { scope: 'openid email profile' },
-        },
-        (tokenset, userinfo, done) => {
-          if (userinfo) {
-            return done(null, userinfo);
-          }
-
-          return done(null, false);
-        },
-      ));
+      passport.use(
+        'oidc',
+        new OidcStrategy(
+          {
+            client,
+            params: { scope: 'openid email profile' },
+          },
+          (tokenset, userinfo, done) => {
+            if (userinfo) {
+              return done(null, userinfo);
+            }
+
+            return done(null, false);
+          },
+        ),
+      );
 
 
       this.isOidcStrategySetup = true;
       this.isOidcStrategySetup = true;
       logger.debug('OidcStrategy: setup is done');
       logger.debug('OidcStrategy: setup is done');
     }
     }
-
   }
   }
 
 
   /**
   /**
@@ -663,7 +785,7 @@ class PassportService implements S2sMessageHandlable {
    * @param issuerHost string
    * @param issuerHost string
    * @returns string URL/.well-known/openid-configuration
    * @returns string URL/.well-known/openid-configuration
    */
    */
-  getOIDCMetadataURL(issuerHost: string) : string {
+  getOIDCMetadataURL(issuerHost: string): string {
     const protocol = 'https://';
     const protocol = 'https://';
     const pattern = /^https?:\/\//i;
     const pattern = /^https?:\/\//i;
     const metadataPath = '/.well-known/openid-configuration';
     const metadataPath = '/.well-known/openid-configuration';
@@ -672,36 +794,36 @@ class PassportService implements S2sMessageHandlable {
       return issuerHost;
       return issuerHost;
     }
     }
     // Set protocol if not available on url
     // Set protocol if not available on url
-    const absUrl = !pattern.test(issuerHost) ? `${protocol}${issuerHost}` : issuerHost;
+    const absUrl = !pattern.test(issuerHost)
+      ? `${protocol}${issuerHost}`
+      : issuerHost;
     const url = new URL(absUrl).href;
     const url = new URL(absUrl).href;
     // Remove trailing slash if exists
     // Remove trailing slash if exists
     return `${url.replace(/\/+$/, '')}${metadataPath}`;
     return `${url.replace(/\/+$/, '')}${metadataPath}`;
   }
   }
 
 
   /**
   /**
- *
- * Check and initialize connection to OIDC issuer host
- * Prevent request timeout error on app init
- *
- * @param issuerHost string
- * @returns boolean
- */
+   *
+   * Check and initialize connection to OIDC issuer host
+   * Prevent request timeout error on app init
+   *
+   * @param issuerHost string
+   * @returns boolean
+   */
   async isOidcHostReachable(issuerHost: string): Promise<boolean | undefined> {
   async isOidcHostReachable(issuerHost: string): Promise<boolean | undefined> {
     try {
     try {
       const metadataUrl = this.getOIDCMetadataURL(issuerHost);
       const metadataUrl = this.getOIDCMetadataURL(issuerHost);
-      const client = require('axios').default;
-      axiosRetry(client, {
+      axiosRetry(axios, {
         retries: 3,
         retries: 3,
       });
       });
-      const response = await client.get(metadataUrl);
+      const response = await axios.get(metadataUrl);
       // Check for valid OIDC Issuer configuration
       // Check for valid OIDC Issuer configuration
       if (!response.data.issuer) {
       if (!response.data.issuer) {
         logger.debug('OidcStrategy: Invalid OIDC Issuer configurations');
         logger.debug('OidcStrategy: Invalid OIDC Issuer configurations');
         return false;
         return false;
       }
       }
       return true;
       return true;
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('OidcStrategy: issuer host unreachable:', err.code);
       logger.error('OidcStrategy: issuer host unreachable:', err.code);
     }
     }
   }
   }
@@ -713,11 +835,20 @@ class PassportService implements S2sMessageHandlable {
    * @param issuerHost string
    * @param issuerHost string
    * @returns instance of OIDCIssuer
    * @returns instance of OIDCIssuer
    */
    */
-  async getOIDCIssuerInstance(issuerHost: string | undefined): Promise<void | OIDCIssuer> {
-    const OIDC_TIMEOUT_MULTIPLIER = configManager.getConfig('security:passport-oidc:timeoutMultiplier');
-    const OIDC_DISCOVERY_RETRIES = configManager.getConfig('security:passport-oidc:discoveryRetries');
-    const OIDC_ISSUER_TIMEOUT_OPTION = configManager.getConfig('security:passport-oidc:oidcIssuerTimeoutOption');
-    const oidcIssuerHostReady = issuerHost != null && this.isOidcHostReachable(issuerHost);
+  async getOIDCIssuerInstance(
+    issuerHost: string | undefined,
+  ): Promise<void | OIDCIssuer> {
+    const OIDC_TIMEOUT_MULTIPLIER = configManager.getConfig(
+      'security:passport-oidc:timeoutMultiplier',
+    );
+    const OIDC_DISCOVERY_RETRIES = configManager.getConfig(
+      'security:passport-oidc:discoveryRetries',
+    );
+    const OIDC_ISSUER_TIMEOUT_OPTION = configManager.getConfig(
+      'security:passport-oidc:oidcIssuerTimeoutOption',
+    );
+    const oidcIssuerHostReady =
+      issuerHost != null && this.isOidcHostReachable(issuerHost);
 
 
     if (!oidcIssuerHostReady) {
     if (!oidcIssuerHostReady) {
       logger.error('OidcStrategy: setup failed');
       logger.error('OidcStrategy: setup failed');
@@ -725,33 +856,39 @@ class PassportService implements S2sMessageHandlable {
     }
     }
 
 
     const metadataURL = this.getOIDCMetadataURL(issuerHost);
     const metadataURL = this.getOIDCMetadataURL(issuerHost);
-    const oidcIssuer = await pRetry(async() => {
-      return OIDCIssuer.discover(metadataURL);
-    }, {
-      onFailedAttempt: (error) => {
-        // get current OIDCIssuer timeout options
-        OIDCIssuer[custom.http_options] = (url, options) => {
-          const timeout = options.timeout
-            ? options.timeout * OIDC_TIMEOUT_MULTIPLIER
-            : OIDC_ISSUER_TIMEOUT_OPTION * OIDC_TIMEOUT_MULTIPLIER;
-          custom.setHttpOptionsDefaults({ timeout });
-          return { timeout };
-        };
-
-        logger.debug(`OidcStrategy: setup attempt ${error.attemptNumber} failed with error: ${error}. Retrying ...`);
+    const oidcIssuer = await pRetry(
+      async () => {
+        return OIDCIssuer.discover(metadataURL);
       },
       },
-      retries: OIDC_DISCOVERY_RETRIES,
-    }).catch((error) => {
+      {
+        onFailedAttempt: (error) => {
+          // get current OIDCIssuer timeout options
+          OIDCIssuer[custom.http_options] = (url, options) => {
+            const timeout = options.timeout
+              ? options.timeout * OIDC_TIMEOUT_MULTIPLIER
+              : OIDC_ISSUER_TIMEOUT_OPTION * OIDC_TIMEOUT_MULTIPLIER;
+            custom.setHttpOptionsDefaults({ timeout });
+            return { timeout };
+          };
+
+          logger.debug(
+            `OidcStrategy: setup attempt ${error.attemptNumber} failed with error: ${error}. Retrying ...`,
+          );
+        },
+        retries: OIDC_DISCOVERY_RETRIES,
+      },
+    ).catch((error) => {
       logger.error(`OidcStrategy: setup failed with error: ${error} `);
       logger.error(`OidcStrategy: setup failed with error: ${error} `);
     });
     });
     return oidcIssuer;
     return oidcIssuer;
   }
   }
 
 
   setupSamlStrategy(): void {
   setupSamlStrategy(): void {
-
     this.resetSamlStrategy();
     this.resetSamlStrategy();
 
 
-    const isSamlEnabled = configManager.getConfig('security:passport-saml:isEnabled');
+    const isSamlEnabled = configManager.getConfig(
+      'security:passport-saml:isEnabled',
+    );
 
 
     // when disabled
     // when disabled
     if (!isSamlEnabled) {
     if (!isSamlEnabled) {
@@ -769,10 +906,16 @@ class PassportService implements S2sMessageHandlable {
     passport.use(
     passport.use(
       new SamlStrategy(
       new SamlStrategy(
         {
         {
-          entryPoint: configManager.getConfig('security:passport-saml:entryPoint'),
-          callbackUrl: configManager.getConfig('app:siteUrl') != null
-            ? urljoin(growiInfoService.getSiteUrl(), '/passport/saml/callback') // auto-generated with v3.2.4 and above
-            : configManager.getConfig('security:passport-saml:callbackUrl'), // DEPRECATED: backward compatible with v3.2.3 and below
+          entryPoint: configManager.getConfig(
+            'security:passport-saml:entryPoint',
+          ),
+          callbackUrl:
+            configManager.getConfig('app:siteUrl') != null
+              ? urljoin(
+                  growiInfoService.getSiteUrl(),
+                  '/passport/saml/callback',
+                ) // auto-generated with v3.2.4 and above
+              : configManager.getConfig('security:passport-saml:callbackUrl'), // DEPRECATED: backward compatible with v3.2.3 and below
           issuer: configManager.getConfig('security:passport-saml:issuer'),
           issuer: configManager.getConfig('security:passport-saml:issuer'),
           cert,
           cert,
           disableRequestedAuthnContext: true,
           disableRequestedAuthnContext: true,
@@ -841,7 +984,9 @@ class PassportService implements S2sMessageHandlable {
     logger.debug({ 'Parsed Rule': JSON.stringify(luceneRule, null, 2) });
     logger.debug({ 'Parsed Rule': JSON.stringify(luceneRule, null, 2) });
 
 
     const attributes = this.extractAttributesFromSAMLResponse(response);
     const attributes = this.extractAttributesFromSAMLResponse(response);
-    logger.debug({ 'Extracted Attributes': JSON.stringify(attributes, null, 2) });
+    logger.debug({
+      'Extracted Attributes': JSON.stringify(attributes, null, 2),
+    });
 
 
     return this.evaluateRuleForSamlAttributes(attributes, luceneRule);
     return this.evaluateRuleForSamlAttributes(attributes, luceneRule);
   }
   }
@@ -858,7 +1003,12 @@ class PassportService implements S2sMessageHandlable {
 
 
     // when combined rules
     // when combined rules
     if (right != null) {
     if (right != null) {
-      return this.evaluateCombinedRulesForSamlAttributes(attributes, left, right, operator);
+      return this.evaluateCombinedRulesForSamlAttributes(
+        attributes,
+        left,
+        right,
+        operator,
+      );
     }
     }
     if (left != null) {
     if (left != null) {
       return this.evaluateRuleForSamlAttributes(attributes, left);
       return this.evaluateRuleForSamlAttributes(attributes, left);
@@ -891,15 +1041,29 @@ class PassportService implements S2sMessageHandlable {
    * @param {string} luceneOperator operator string expression
    * @param {string} luceneOperator operator string expression
    * @see https://github.com/thoward/lucene-query-parser.js/wiki
    * @see https://github.com/thoward/lucene-query-parser.js/wiki
    */
    */
-  evaluateCombinedRulesForSamlAttributes(attributes, luceneRuleLeft, luceneRuleRight, luceneOperator) {
+  evaluateCombinedRulesForSamlAttributes(
+    attributes,
+    luceneRuleLeft,
+    luceneRuleRight,
+    luceneOperator,
+  ) {
     if (luceneOperator === 'OR') {
     if (luceneOperator === 'OR') {
-      return this.evaluateRuleForSamlAttributes(attributes, luceneRuleLeft) || this.evaluateRuleForSamlAttributes(attributes, luceneRuleRight);
+      return (
+        this.evaluateRuleForSamlAttributes(attributes, luceneRuleLeft) ||
+        this.evaluateRuleForSamlAttributes(attributes, luceneRuleRight)
+      );
     }
     }
     if (luceneOperator === 'AND') {
     if (luceneOperator === 'AND') {
-      return this.evaluateRuleForSamlAttributes(attributes, luceneRuleLeft) && this.evaluateRuleForSamlAttributes(attributes, luceneRuleRight);
+      return (
+        this.evaluateRuleForSamlAttributes(attributes, luceneRuleLeft) &&
+        this.evaluateRuleForSamlAttributes(attributes, luceneRuleRight)
+      );
     }
     }
     if (luceneOperator === 'NOT') {
     if (luceneOperator === 'NOT') {
-      return this.evaluateRuleForSamlAttributes(attributes, luceneRuleLeft) && !this.evaluateRuleForSamlAttributes(attributes, luceneRuleRight);
+      return (
+        this.evaluateRuleForSamlAttributes(attributes, luceneRuleLeft) &&
+        !this.evaluateRuleForSamlAttributes(attributes, luceneRuleRight)
+      );
     }
     }
 
 
     throw new Error(`Unsupported operator: ${luceneOperator}`);
     throw new Error(`Unsupported operator: ${luceneOperator}`);
@@ -917,7 +1081,8 @@ class PassportService implements S2sMessageHandlable {
    * }
    * }
    */
    */
   extractAttributesFromSAMLResponse(response) {
   extractAttributesFromSAMLResponse(response) {
-    const attributeStatement = response.getAssertion().Assertion.AttributeStatement;
+    const attributeStatement =
+      response.getAssertion().Assertion.AttributeStatement;
     if (attributeStatement == null || attributeStatement[0] == null) {
     if (attributeStatement == null || attributeStatement[0] == null) {
       return {};
       return {};
     }
     }
@@ -930,11 +1095,10 @@ class PassportService implements S2sMessageHandlable {
     const result = {};
     const result = {};
     for (const attribute of attributes) {
     for (const attribute of attributes) {
       const name = attribute.$.Name;
       const name = attribute.$.Name;
-      const attributeValues = attribute.AttributeValue.map(v => v._);
+      const attributeValues = attribute.AttributeValue.map((v) => v._);
       if (result[name] == null) {
       if (result[name] == null) {
         result[name] = attributeValues;
         result[name] = attributeValues;
-      }
-      else {
+      } else {
         result[name] = result[name].concat(attributeValues);
         result[name] = result[name].concat(attributeValues);
       }
       }
     }
     }
@@ -961,7 +1125,7 @@ class PassportService implements S2sMessageHandlable {
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
       done(null, (user as any).id);
       done(null, (user as any).id);
     });
     });
-    passport.deserializeUser(async(id, done) => {
+    passport.deserializeUser(async (id, done) => {
       try {
       try {
         const user = await User.findById(id);
         const user = await User.findById(id);
         if (user == null) {
         if (user == null) {
@@ -972,8 +1136,7 @@ class PassportService implements S2sMessageHandlable {
           await user.save();
           await user.save();
         }
         }
         done(null, user);
         done(null, user);
-      }
-      catch (err) {
+      } catch (err) {
         done(err);
         done(err);
       }
       }
     });
     });
@@ -981,12 +1144,20 @@ class PassportService implements S2sMessageHandlable {
     this.isSerializerSetup = true;
     this.isSerializerSetup = true;
   }
   }
 
 
-  isSameUsernameTreatedAsIdenticalUser(providerType: IExternalAuthProviderType): boolean {
-    return configManager.getConfig(`security:passport-${providerType}:isSameUsernameTreatedAsIdenticalUser`);
+  isSameUsernameTreatedAsIdenticalUser(
+    providerType: IExternalAuthProviderType,
+  ): boolean {
+    return configManager.getConfig(
+      `security:passport-${providerType}:isSameUsernameTreatedAsIdenticalUser`,
+    );
   }
   }
 
 
-  isSameEmailTreatedAsIdenticalUser(providerType: Exclude<IExternalAuthProviderType, 'ldap'>): boolean {
-    return configManager.getConfig(`security:passport-${providerType}:isSameEmailTreatedAsIdenticalUser`);
+  isSameEmailTreatedAsIdenticalUser(
+    providerType: Exclude<IExternalAuthProviderType, 'ldap'>,
+  ): boolean {
+    return configManager.getConfig(
+      `security:passport-${providerType}:isSameEmailTreatedAsIdenticalUser`,
+    );
   }
   }
 
 
   literalUnescape(string: string) {
   literalUnescape(string: string) {
@@ -1000,7 +1171,6 @@ class PassportService implements S2sMessageHandlable {
       .replace(/\\n/g, '\n')
       .replace(/\\n/g, '\n')
       .replace(/\\r/g, '\r');
       .replace(/\\r/g, '\r');
   }
   }
-
 }
 }
 
 
 export default PassportService;
 export default PassportService;

+ 27 - 22
apps/app/src/server/service/pre-notify.ts

@@ -1,44 +1,52 @@
-import {
-  getIdForRef,
-  type IPage, type IUser, type Ref,
-} from '@growi/core';
+import { getIdForRef, type IPage, type IUser, type Ref } from '@growi/core';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 import type { ActivityDocument } from '../models/activity';
 import type { ActivityDocument } from '../models/activity';
 import Subscription from '../models/subscription';
 import Subscription from '../models/subscription';
 
 
 export type PreNotifyProps = {
 export type PreNotifyProps = {
-  notificationTargetUsers?: Ref<IUser>[],
-}
+  notificationTargetUsers?: Ref<IUser>[];
+};
 
 
 export type PreNotify = (props: PreNotifyProps) => Promise<void>;
 export type PreNotify = (props: PreNotifyProps) => Promise<void>;
-export type GetAdditionalTargetUsers = (activity: ActivityDocument) => Promise<Ref<IUser>[]>;
-export type GeneratePreNotify = (activity: ActivityDocument, getAdditionalTargetUsers?: GetAdditionalTargetUsers) => PreNotify;
+export type GetAdditionalTargetUsers = (
+  activity: ActivityDocument,
+) => Promise<Ref<IUser>[]>;
+export type GeneratePreNotify = (
+  activity: ActivityDocument,
+  getAdditionalTargetUsers?: GetAdditionalTargetUsers,
+) => PreNotify;
 
 
 interface IPreNotifyService {
 interface IPreNotifyService {
-  generateInitialPreNotifyProps: (PreNotifyProps) => { notificationTargetUsers?: Ref<IUser>[] },
-  generatePreNotify: GeneratePreNotify
+  generateInitialPreNotifyProps: (PreNotifyProps) => {
+    notificationTargetUsers?: Ref<IUser>[];
+  };
+  generatePreNotify: GeneratePreNotify;
 }
 }
 
 
 class PreNotifyService implements IPreNotifyService {
 class PreNotifyService implements IPreNotifyService {
-
   generateInitialPreNotifyProps = (): PreNotifyProps => {
   generateInitialPreNotifyProps = (): PreNotifyProps => {
-
     const initialPreNotifyProps: Ref<IUser>[] = [];
     const initialPreNotifyProps: Ref<IUser>[] = [];
 
 
     return { notificationTargetUsers: initialPreNotifyProps };
     return { notificationTargetUsers: initialPreNotifyProps };
   };
   };
 
 
-  generatePreNotify = (activity: ActivityDocument, getAdditionalTargetUsers?: GetAdditionalTargetUsers): PreNotify => {
-
-    const preNotify = async(props: PreNotifyProps) => {
+  generatePreNotify = (
+    activity: ActivityDocument,
+    getAdditionalTargetUsers?: GetAdditionalTargetUsers,
+  ): PreNotify => {
+    const preNotify = async (props: PreNotifyProps) => {
       const { notificationTargetUsers } = props;
       const { notificationTargetUsers } = props;
 
 
-      const User = mongoose.model<IUser, { find, STATUS_ACTIVE }>('User');
+      const User = mongoose.model<IUser, { find; STATUS_ACTIVE }>('User');
       const actionUser = activity.user;
       const actionUser = activity.user;
       const target = activity.target;
       const target = activity.target;
-      const subscribedUsers = await Subscription.getSubscription(target as unknown as Ref<IPage>);
-      const notificationUsers = subscribedUsers.filter(item => (item.toString() !== getIdForRef(actionUser).toString()));
+      const subscribedUsers = await Subscription.getSubscription(
+        target as unknown as Ref<IPage>,
+      );
+      const notificationUsers = subscribedUsers.filter(
+        (item) => item.toString() !== getIdForRef(actionUser).toString(),
+      );
       const activeNotificationUsers = await User.find({
       const activeNotificationUsers = await User.find({
         _id: { $in: notificationUsers },
         _id: { $in: notificationUsers },
         status: User.STATUS_ACTIVE,
         status: User.STATUS_ACTIVE,
@@ -46,8 +54,7 @@ class PreNotifyService implements IPreNotifyService {
 
 
       if (getAdditionalTargetUsers == null) {
       if (getAdditionalTargetUsers == null) {
         notificationTargetUsers?.push(...activeNotificationUsers);
         notificationTargetUsers?.push(...activeNotificationUsers);
-      }
-      else {
+      } else {
         const AdditionalTargetUsers = await getAdditionalTargetUsers(activity);
         const AdditionalTargetUsers = await getAdditionalTargetUsers(activity);
 
 
         notificationTargetUsers?.push(
         notificationTargetUsers?.push(
@@ -55,12 +62,10 @@ class PreNotifyService implements IPreNotifyService {
           ...AdditionalTargetUsers,
           ...AdditionalTargetUsers,
         );
         );
       }
       }
-
     };
     };
 
 
     return preNotify;
     return preNotify;
   };
   };
-
 }
 }
 
 
 export const preNotifyService = new PreNotifyService();
 export const preNotifyService = new PreNotifyService();

+ 9 - 10
apps/app/src/server/service/rest-qiita-API.js

@@ -1,4 +1,5 @@
 function getAxios(team, token) {
 function getAxios(team, token) {
+  // biome-ignore lint/style/noRestrictedImports: TODO: check effects of using custom axios
   return require('axios').create({
   return require('axios').create({
     baseURL: `https://${team}.qiita.com/api/v2`,
     baseURL: `https://${team}.qiita.com/api/v2`,
     headers: {
     headers: {
@@ -16,7 +17,6 @@ function getAxios(team, token) {
  */
  */
 
 
 class RestQiitaAPIService {
 class RestQiitaAPIService {
-
   /** @type {import('~/server/crowi').default} Crowi instance */
   /** @type {import('~/server/crowi').default} Crowi instance */
   crowi;
   crowi;
 
 
@@ -46,13 +46,12 @@ class RestQiitaAPIService {
    * @param {string} path
    * @param {string} path
    */
    */
   async restAPI(path) {
   async restAPI(path) {
-    return this.axios.get(path)
-      .then((res) => {
-        const data = res.data;
-        const total = res.headers['total-count'];
+    return this.axios.get(path).then((res) => {
+      const data = res.data;
+      const total = res.headers['total-count'];
 
 
-        return { data, total };
-      });
+      return { data, total };
+    });
   }
   }
 
 
   /**
   /**
@@ -68,7 +67,6 @@ class RestQiitaAPIService {
     }
     }
   }
   }
 
 
-
   /**
   /**
    * get Qiita pages
    * get Qiita pages
    * @memberof RestQiitaAPI
    * @memberof RestQiitaAPI
@@ -76,7 +74,9 @@ class RestQiitaAPIService {
    * @param {string} perPage
    * @param {string} perPage
    */
    */
   async getQiitaPages(pageNum, perPage) {
   async getQiitaPages(pageNum, perPage) {
-    const res = await this.restAPI(`/items?page=${pageNum}&per_page=${perPage}`);
+    const res = await this.restAPI(
+      `/items?page=${pageNum}&per_page=${perPage}`,
+    );
     const pages = res.data;
     const pages = res.data;
     const total = res.total;
     const total = res.total;
 
 
@@ -84,7 +84,6 @@ class RestQiitaAPIService {
       return { pages, total };
       return { pages, total };
     }
     }
   }
   }
-
 }
 }
 
 
 module.exports = RestQiitaAPIService;
 module.exports = RestQiitaAPIService;

+ 26 - 17
apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts

@@ -9,14 +9,14 @@ import { Revision } from '~/server/models/revision';
 import { normalizeLatestRevisionIfBroken } from './normalize-latest-revision-if-broken';
 import { normalizeLatestRevisionIfBroken } from './normalize-latest-revision-if-broken';
 
 
 describe('normalizeLatestRevisionIfBroken', () => {
 describe('normalizeLatestRevisionIfBroken', () => {
-
-  beforeAll(async() => {
+  beforeAll(async () => {
     await PageModelFactory(null);
     await PageModelFactory(null);
   });
   });
 
 
-
-  test('should update the latest revision', async() => {
-    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+  test('should update the latest revision', async () => {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+      'Page',
+    );
 
 
     // == Arrange
     // == Arrange
     const page = await Page.create({ path: '/foo' });
     const page = await Page.create({ path: '/foo' });
@@ -25,7 +25,10 @@ describe('normalizeLatestRevisionIfBroken', () => {
     page.revision = revision._id;
     page.revision = revision._id;
     await page.save();
     await page.save();
     // break the revision
     // break the revision
-    await Revision.updateOne({ _id: revision._id }, { pageId: new Types.ObjectId() });
+    await Revision.updateOne(
+      { _id: revision._id },
+      { pageId: new Types.ObjectId() },
+    );
 
 
     // spy
     // spy
     const updateOneSpy = vi.spyOn(Revision, 'updateOne');
     const updateOneSpy = vi.spyOn(Revision, 'updateOne');
@@ -48,10 +51,11 @@ describe('normalizeLatestRevisionIfBroken', () => {
     expect(getIdStringForRef(revisionById.pageId)).toEqual(page._id.toString());
     expect(getIdStringForRef(revisionById.pageId)).toEqual(page._id.toString());
   });
   });
 
 
-
   describe('should returns without any operation', () => {
   describe('should returns without any operation', () => {
-    test('when the page has revisions at least one', async() => {
-      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    test('when the page has revisions at least one', async () => {
+      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+        'Page',
+      );
 
 
       // Arrange
       // Arrange
       const page = await Page.create({ path: '/foo' });
       const page = await Page.create({ path: '/foo' });
@@ -66,7 +70,7 @@ describe('normalizeLatestRevisionIfBroken', () => {
       expect(updateOneSpy).not.toHaveBeenCalled();
       expect(updateOneSpy).not.toHaveBeenCalled();
     });
     });
 
 
-    test('when the page is not found', async() => {
+    test('when the page is not found', async () => {
       // Arrange
       // Arrange
       const pageIdOfRevision = new Types.ObjectId();
       const pageIdOfRevision = new Types.ObjectId();
       // create an orphan revision
       // create an orphan revision
@@ -82,8 +86,10 @@ describe('normalizeLatestRevisionIfBroken', () => {
       expect(updateOneSpy).not.toHaveBeenCalled();
       expect(updateOneSpy).not.toHaveBeenCalled();
     });
     });
 
 
-    test('when the page.revision is null', async() => {
-      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    test('when the page.revision is null', async () => {
+      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+        'Page',
+      );
 
 
       // Arrange
       // Arrange
       const page = await Page.create({ path: '/foo' });
       const page = await Page.create({ path: '/foo' });
@@ -100,12 +106,17 @@ describe('normalizeLatestRevisionIfBroken', () => {
       expect(updateOneSpy).not.toHaveBeenCalled();
       expect(updateOneSpy).not.toHaveBeenCalled();
     });
     });
 
 
-    test('when the page.revision does not exist', async() => {
-      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    test('when the page.revision does not exist', async () => {
+      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+        'Page',
+      );
 
 
       // Arrange
       // Arrange
       const revisionNonExistent = new Types.ObjectId();
       const revisionNonExistent = new Types.ObjectId();
-      const page = await Page.create({ path: '/foo', revision: revisionNonExistent });
+      const page = await Page.create({
+        path: '/foo',
+        revision: revisionNonExistent,
+      });
       // create an orphan revision
       // create an orphan revision
       await Revision.create({ pageId: page._id, body: '' });
       await Revision.create({ pageId: page._id, body: '' });
 
 
@@ -118,7 +129,5 @@ describe('normalizeLatestRevisionIfBroken', () => {
       // Assert
       // Assert
       expect(updateOneSpy).not.toHaveBeenCalled();
       expect(updateOneSpy).not.toHaveBeenCalled();
     });
     });
-
   });
   });
-
 });
 });

+ 26 - 10
apps/app/src/server/service/revision/normalize-latest-revision-if-broken.ts

@@ -5,31 +5,47 @@ import type { PageDocument, PageModel } from '~/server/models/page';
 import { Revision } from '~/server/models/revision';
 import { Revision } from '~/server/models/revision';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
-const logger = loggerFactory('growi:service:revision:normalize-latest-revision');
+const logger = loggerFactory(
+  'growi:service:revision:normalize-latest-revision',
+);
 
 
 /**
 /**
  * Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
  * Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
  *
  *
  * @ref https://github.com/growilabs/growi/pull/8998
  * @ref https://github.com/growilabs/growi/pull/8998
  */
  */
-export const normalizeLatestRevisionIfBroken = async(pageId: string | Types.ObjectId): Promise<void> => {
-
+export const normalizeLatestRevisionIfBroken = async (
+  pageId: string | Types.ObjectId,
+): Promise<void> => {
   if (await Revision.exists({ pageId: { $eq: pageId } })) {
   if (await Revision.exists({ pageId: { $eq: pageId } })) {
     return;
     return;
   }
   }
 
 
-  logger.info(`The page ('${pageId}') does not have any revisions. Normalization of the latest revision will be started.`);
+  logger.info(
+    `The page ('${pageId}') does not have any revisions. Normalization of the latest revision will be started.`,
+  );
 
 
-  const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
-  const page = await Page.findOne({ _id: { $eq: pageId } }, { revision: 1 }).exec();
+  const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+    'Page',
+  );
+  const page = await Page.findOne(
+    { _id: { $eq: pageId } },
+    { revision: 1 },
+  ).exec();
 
 
   if (page == null) {
   if (page == null) {
-    logger.warn(`Normalization has been canceled since the page ('${pageId}') could not be found.`);
+    logger.warn(
+      `Normalization has been canceled since the page ('${pageId}') could not be found.`,
+    );
     return;
     return;
   }
   }
-  if (page.revision == null || !(await Revision.exists({ _id: page.revision }))) {
-    logger.warn(`Normalization has been canceled since the Page.revision of the page ('${pageId}') could not be found.`);
+  if (
+    page.revision == null ||
+    !(await Revision.exists({ _id: page.revision }))
+  ) {
+    logger.warn(
+      `Normalization has been canceled since the Page.revision of the page ('${pageId}') could not be found.`,
+    );
     return;
     return;
   }
   }
 
 

+ 4 - 6
apps/app/src/server/service/s2s-messaging/base.ts

@@ -9,7 +9,6 @@ import type { S2sMessageHandlable } from './handlable';
 const logger = loggerFactory('growi:service:s2s-messaging:base');
 const logger = loggerFactory('growi:service:s2s-messaging:base');
 
 
 export interface S2sMessagingService {
 export interface S2sMessagingService {
-
   uid: number;
   uid: number;
 
 
   uri: string;
   uri: string;
@@ -37,11 +36,11 @@ export interface S2sMessagingService {
    * @param handlable
    * @param handlable
    */
    */
   removeMessageHandler(handlable: S2sMessageHandlable): void;
   removeMessageHandler(handlable: S2sMessageHandlable): void;
-
 }
 }
 
 
-export abstract class AbstractS2sMessagingService implements S2sMessagingService {
-
+export abstract class AbstractS2sMessagingService
+  implements S2sMessagingService
+{
   uid: number;
   uid: number;
 
 
   uri: string;
   uri: string;
@@ -84,7 +83,6 @@ export abstract class AbstractS2sMessagingService implements S2sMessagingService
    * @param handlable
    * @param handlable
    */
    */
   removeMessageHandler(handlable: S2sMessageHandlable): void {
   removeMessageHandler(handlable: S2sMessageHandlable): void {
-    this.handlableList = this.handlableList.filter(h => h !== handlable);
+    this.handlableList = this.handlableList.filter((h) => h !== handlable);
   }
   }
-
 }
 }

+ 0 - 2
apps/app/src/server/service/s2s-messaging/handlable.ts

@@ -2,9 +2,7 @@
  * The interface to handle server-to-server message
  * The interface to handle server-to-server message
  */
  */
 export interface S2sMessageHandlable {
 export interface S2sMessageHandlable {
-
   shouldHandleS2sMessage(s2sMessage): boolean;
   shouldHandleS2sMessage(s2sMessage): boolean;
 
 
   handleS2sMessage(s2sMessage): Promise<void>;
   handleS2sMessage(s2sMessage): Promise<void>;
-
 }
 }

+ 3 - 3
apps/app/src/server/service/s2s-messaging/index.ts

@@ -3,7 +3,9 @@ import loggerFactory from '~/utils/logger';
 
 
 import type { S2sMessagingService } from './base';
 import type { S2sMessagingService } from './base';
 
 
-const logger = loggerFactory('growi:service:s2s-messaging:S2sMessagingServiceFactory');
+const logger = loggerFactory(
+  'growi:service:s2s-messaging:S2sMessagingServiceFactory',
+);
 
 
 const envToModuleMappings = {
 const envToModuleMappings = {
   redis: 'redis',
   redis: 'redis',
@@ -40,7 +42,6 @@ const envToModuleMappings = {
  * Instanciate server-to-server messaging service
  * Instanciate server-to-server messaging service
  */
  */
 class S2sMessagingServiceFactory {
 class S2sMessagingServiceFactory {
-
   delegator!: S2sMessagingService;
   delegator!: S2sMessagingService;
 
 
   initializeDelegator(crowi: Crowi) {
   initializeDelegator(crowi: Crowi) {
@@ -70,7 +71,6 @@ class S2sMessagingServiceFactory {
     }
     }
     return this.delegator;
     return this.delegator;
   }
   }
-
 }
 }
 
 
 const factory = new S2sMessagingServiceFactory();
 const factory = new S2sMessagingServiceFactory();

+ 38 - 22
apps/app/src/server/service/s2s-messaging/nchan.ts

@@ -1,7 +1,6 @@
-import path from 'path';
-
 // biome-ignore lint/style/noRestrictedImports: Direct axios usage for external S2S messaging
 // biome-ignore lint/style/noRestrictedImports: Direct axios usage for external S2S messaging
 import axios from 'axios';
 import axios from 'axios';
+import path from 'path';
 import ReconnectingWebSocket from 'reconnecting-websocket';
 import ReconnectingWebSocket from 'reconnecting-websocket';
 import WebSocket from 'ws';
 import WebSocket from 'ws';
 
 
@@ -9,14 +8,11 @@ import type Crowi from '~/server/crowi';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import S2sMessage from '../../models/vo/s2s-message';
 import S2sMessage from '../../models/vo/s2s-message';
-
 import { AbstractS2sMessagingService } from './base';
 import { AbstractS2sMessagingService } from './base';
 
 
 const logger = loggerFactory('growi:service:s2s-messaging:nchan');
 const logger = loggerFactory('growi:service:s2s-messaging:nchan');
 
 
-
 class NchanDelegator extends AbstractS2sMessagingService {
 class NchanDelegator extends AbstractS2sMessagingService {
-
   /**
   /**
    * A list of S2sMessageHandlable instance
    * A list of S2sMessageHandlable instance
    */
    */
@@ -24,7 +20,12 @@ class NchanDelegator extends AbstractS2sMessagingService {
 
 
   socket: any = null;
   socket: any = null;
 
 
-  constructor(uri, private publishPath: string, private subscribePath: string, private channelId: any) {
+  constructor(
+    uri,
+    private publishPath: string,
+    private subscribePath: string,
+    private channelId: any,
+  ) {
     super(uri);
     super(uri);
   }
   }
 
 
@@ -41,9 +42,10 @@ class NchanDelegator extends AbstractS2sMessagingService {
   subscribe(forceReconnect = false) {
   subscribe(forceReconnect = false) {
     if (forceReconnect) {
     if (forceReconnect) {
       logger.info('Force reconnecting is requested. Try to reconnect...');
       logger.info('Force reconnecting is requested. Try to reconnect...');
-    }
-    else if (this.socket != null && this.shouldResubscribe()) {
-      logger.info('The connection to config pubsub server is offline. Try to reconnect...');
+    } else if (this.socket != null && this.shouldResubscribe()) {
+      logger.info(
+        'The connection to config pubsub server is offline. Try to reconnect...',
+      );
     }
     }
 
 
     // init client
     // init client
@@ -111,9 +113,10 @@ class NchanDelegator extends AbstractS2sMessagingService {
   }
   }
 
 
   constructUrl(basepath) {
   constructUrl(basepath) {
-    const pathname = this.channelId == null
-      ? basepath //                                 /pubsub
-      : path.join(basepath, this.channelId); //     /pubsub/my-channel-id
+    const pathname =
+      this.channelId == null
+        ? basepath //                                 /pubsub
+        : path.join(basepath, this.channelId); //     /pubsub/my-channel-id
 
 
     return new URL(pathname, this.uri);
     return new URL(pathname, this.uri);
   }
   }
@@ -138,7 +141,9 @@ class NchanDelegator extends AbstractS2sMessagingService {
       logger.info('WebSocket client connected.');
       logger.info('WebSocket client connected.');
     });
     });
 
 
-    this.handlableList.forEach(handlable => this.registerMessageHandlerToSocket(handlable));
+    this.handlableList.forEach((handlable) => {
+      this.registerMessageHandlerToSocket(handlable);
+    });
 
 
     this.socket = socket;
     this.socket = socket;
   }
   }
@@ -157,26 +162,31 @@ class NchanDelegator extends AbstractS2sMessagingService {
 
 
       // check uid
       // check uid
       if (s2sMessage.publisherUid === this.uid) {
       if (s2sMessage.publisherUid === this.uid) {
-        logger.debug(`Skip processing by ${handlable.constructor.name} because this message is sent by the publisher itself:`, `from ${this.uid}`);
+        logger.debug(
+          `Skip processing by ${handlable.constructor.name} because this message is sent by the publisher itself:`,
+          `from ${this.uid}`,
+        );
         return;
         return;
       }
       }
 
 
       // check shouldHandleS2sMessage
       // check shouldHandleS2sMessage
       const shouldHandle = handlable.shouldHandleS2sMessage(s2sMessage);
       const shouldHandle = handlable.shouldHandleS2sMessage(s2sMessage);
-      logger.debug(`${handlable.constructor.name}.shouldHandleS2sMessage(`, s2sMessage, `) => ${shouldHandle}`);
+      logger.debug(
+        `${handlable.constructor.name}.shouldHandleS2sMessage(`,
+        s2sMessage,
+        `) => ${shouldHandle}`,
+      );
 
 
       if (shouldHandle) {
       if (shouldHandle) {
         handlable.handleS2sMessage(s2sMessage);
         handlable.handleS2sMessage(s2sMessage);
       }
       }
-    }
-    catch (err) {
+    } catch (err) {
       logger.warn('Could not handle a message: ', err.message);
       logger.warn('Could not handle a message: ', err.message);
     }
     }
   }
   }
-
 }
 }
 
 
-module.exports = function(crowi: Crowi) {
+module.exports = (crowi: Crowi) => {
   const { configManager } = crowi;
   const { configManager } = crowi;
 
 
   const uri = configManager.getConfig('app:nchanUri');
   const uri = configManager.getConfig('app:nchanUri');
@@ -187,9 +197,15 @@ module.exports = function(crowi: Crowi) {
     return;
     return;
   }
   }
 
 
-  const publishPath = configManager.getConfig('s2sMessagingPubsub:nchan:publishPath');
-  const subscribePath = configManager.getConfig('s2sMessagingPubsub:nchan:subscribePath');
-  const channelId = configManager.getConfig('s2sMessagingPubsub:nchan:channelId');
+  const publishPath = configManager.getConfig(
+    's2sMessagingPubsub:nchan:publishPath',
+  );
+  const subscribePath = configManager.getConfig(
+    's2sMessagingPubsub:nchan:subscribePath',
+  );
+  const channelId = configManager.getConfig(
+    's2sMessagingPubsub:nchan:channelId',
+  );
 
 
   return new NchanDelegator(uri, publishPath, subscribePath, channelId);
   return new NchanDelegator(uri, publishPath, subscribePath, channelId);
 };
 };

+ 1 - 1
apps/app/src/server/service/s2s-messaging/redis.ts

@@ -3,6 +3,6 @@ import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:service:s2s-messaging:redis');
 const logger = loggerFactory('growi:service:s2s-messaging:redis');
 
 
-module.exports = function(crowi: Crowi) {
+module.exports = (crowi: Crowi) => {
   logger.warn('Config pub/sub with Redis has not implemented yet.');
   logger.warn('Config pub/sub with Redis has not implemented yet.');
 };
 };

+ 7 - 5
apps/app/src/server/service/search-delegator/aggregate-to-index.ts

@@ -3,11 +3,11 @@ import type { PipelineStage, Query } from 'mongoose';
 
 
 import type { PageModel } from '~/server/models/page';
 import type { PageModel } from '~/server/models/page';
 
 
-export const aggregatePipelineToIndex = (maxBodyLengthToIndex: number, query?: Query<PageModel, IPage>): PipelineStage[] => {
-
-  const basePipeline = query == null
-    ? []
-    : [{ $match: query.getQuery() }];
+export const aggregatePipelineToIndex = (
+  maxBodyLengthToIndex: number,
+  query?: Query<PageModel, IPage>,
+): PipelineStage[] => {
+  const basePipeline = query == null ? [] : [{ $match: query.getQuery() }];
 
 
   return [
   return [
     ...basePipeline,
     ...basePipeline,
@@ -110,6 +110,7 @@ export const aggregatePipelineToIndex = (maxBodyLengthToIndex: number, query?: Q
         'revision.body': {
         'revision.body': {
           $cond: {
           $cond: {
             if: { $lte: ['$bodyLength', maxBodyLengthToIndex] },
             if: { $lte: ['$bodyLength', maxBodyLengthToIndex] },
+            // biome-ignore lint/suspicious/noThenProperty: ignore
             then: '$revision.body',
             then: '$revision.body',
             else: '',
             else: '',
           },
           },
@@ -121,6 +122,7 @@ export const aggregatePipelineToIndex = (maxBodyLengthToIndex: number, query?: Q
             in: {
             in: {
               $cond: {
               $cond: {
                 if: { $lte: ['$$comment.commentLength', maxBodyLengthToIndex] },
                 if: { $lte: ['$$comment.commentLength', maxBodyLengthToIndex] },
+                // biome-ignore lint/suspicious/noThenProperty: ignore
                 then: '$$comment.comment',
                 then: '$$comment.comment',
                 else: '',
                 else: '',
               },
               },

+ 23 - 22
apps/app/src/server/service/search-delegator/bulk-write.d.ts

@@ -1,7 +1,8 @@
 import type { IPageHasId, PageGrant } from '@growi/core';
 import type { IPageHasId, PageGrant } from '@growi/core';
 
 
-export type AggregatedPage = Pick<IPageHasId,
-  '_id'
+export type AggregatedPage = Pick<
+  IPageHasId,
+  | '_id'
   | 'path'
   | 'path'
   | 'createdAt'
   | 'createdAt'
   | 'updatedAt'
   | 'updatedAt'
@@ -9,34 +10,34 @@ export type AggregatedPage = Pick<IPageHasId,
   | 'grantedUsers'
   | 'grantedUsers'
   | 'grantedGroups'
   | 'grantedGroups'
 > & {
 > & {
-  revision: { body: string },
-  comments: string[],
-  commentsCount: number,
-  bookmarksCount: number,
-  likeCount: number,
-  seenUsersCount: number,
+  revision: { body: string };
+  comments: string[];
+  commentsCount: number;
+  bookmarksCount: number;
+  likeCount: number;
+  seenUsersCount: number;
   creator?: {
   creator?: {
-    username: string,
-    email: string,
-  },
+    username: string;
+    email: string;
+  };
 } & {
 } & {
-  tagNames: string[],
-  revisionBodyEmbedded?: number[],
+  tagNames: string[];
+  revisionBodyEmbedded?: number[];
 };
 };
 
 
 export type BulkWriteCommand = {
 export type BulkWriteCommand = {
   index: {
   index: {
-    _index: string,
-    _type: '_doc' | undefined,
-    _id: string,
-  },
-}
+    _index: string;
+    _type: '_doc' | undefined;
+    _id: string;
+  };
+};
 
 
 export type BulkWriteBodyRestriction = {
 export type BulkWriteBodyRestriction = {
-  grant: PageGrant,
-  granted_users?: string[],
-  granted_groups: string[],
-}
+  grant: PageGrant;
+  granted_users?: string[];
+  granted_groups: string[];
+};
 
 
 export type BulkWriteBody = {
 export type BulkWriteBody = {
   path: string;
   path: string;

+ 67 - 23
apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es7-client-delegator.ts

@@ -1,16 +1,15 @@
 // TODO: https://redmine.weseek.co.jp/issues/168446
 // TODO: https://redmine.weseek.co.jp/issues/168446
 import {
 import {
+  type ApiResponse,
   Client,
   Client,
   type ClientOptions,
   type ClientOptions,
-  type ApiResponse,
-  type RequestParams,
   type estypes,
   type estypes,
+  type RequestParams,
 } from '@elastic/elasticsearch7';
 } from '@elastic/elasticsearch7';
 
 
 import type { ES7SearchQuery } from './interfaces';
 import type { ES7SearchQuery } from './interfaces';
 
 
 export class ES7ClientDelegator {
 export class ES7ClientDelegator {
-
   private client: Client;
   private client: Client;
 
 
   delegatorVersion = 7 as const;
   delegatorVersion = 7 as const;
@@ -25,53 +24,98 @@ export class ES7ClientDelegator {
   }
   }
 
 
   cat = {
   cat = {
-    aliases: (params: RequestParams.CatAliases): Promise<ApiResponse<estypes.CatAliasesResponse>> => this.client.cat.aliases(params),
-    indices: (params: RequestParams.CatIndices): Promise<ApiResponse<estypes.CatIndicesResponse>> => this.client.cat.indices(params),
+    aliases: (
+      params: RequestParams.CatAliases,
+    ): Promise<ApiResponse<estypes.CatAliasesResponse>> =>
+      this.client.cat.aliases(params),
+    indices: (
+      params: RequestParams.CatIndices,
+    ): Promise<ApiResponse<estypes.CatIndicesResponse>> =>
+      this.client.cat.indices(params),
   };
   };
 
 
   cluster = {
   cluster = {
-    health: (): Promise<ApiResponse<estypes.ClusterHealthResponse>> => this.client.cluster.health(),
+    health: (): Promise<ApiResponse<estypes.ClusterHealthResponse>> =>
+      this.client.cluster.health(),
   };
   };
 
 
   indices = {
   indices = {
-    create: (params: RequestParams.IndicesCreate): Promise<ApiResponse<estypes.IndicesCreateResponse>> => this.client.indices.create(params),
-    delete: (params: RequestParams.IndicesDelete): Promise<ApiResponse<estypes.IndicesDeleteResponse>> => this.client.indices.delete(params),
-    exists: async(params: RequestParams.IndicesExists): Promise<estypes.IndicesExistsResponse> => {
+    create: (
+      params: RequestParams.IndicesCreate,
+    ): Promise<ApiResponse<estypes.IndicesCreateResponse>> =>
+      this.client.indices.create(params),
+    delete: (
+      params: RequestParams.IndicesDelete,
+    ): Promise<ApiResponse<estypes.IndicesDeleteResponse>> =>
+      this.client.indices.delete(params),
+    exists: async (
+      params: RequestParams.IndicesExists,
+    ): Promise<estypes.IndicesExistsResponse> => {
       return (await this.client.indices.exists(params)).body;
       return (await this.client.indices.exists(params)).body;
     },
     },
-    existsAlias: async(params: RequestParams.IndicesExistsAlias): Promise<estypes.IndicesExistsAliasResponse> => {
+    existsAlias: async (
+      params: RequestParams.IndicesExistsAlias,
+    ): Promise<estypes.IndicesExistsAliasResponse> => {
       return (await this.client.indices.existsAlias(params)).body;
       return (await this.client.indices.existsAlias(params)).body;
     },
     },
-    putAlias: (params: RequestParams.IndicesPutAlias): Promise<ApiResponse<estypes.IndicesUpdateAliasesResponse>> => this.client.indices.putAlias(params),
-    getAlias: async(params: RequestParams.IndicesGetAlias): Promise<estypes.IndicesGetAliasResponse> => {
-      return (await this.client.indices.getAlias<estypes.IndicesGetAliasResponse>(params)).body;
+    putAlias: (
+      params: RequestParams.IndicesPutAlias,
+    ): Promise<ApiResponse<estypes.IndicesUpdateAliasesResponse>> =>
+      this.client.indices.putAlias(params),
+    getAlias: async (
+      params: RequestParams.IndicesGetAlias,
+    ): Promise<estypes.IndicesGetAliasResponse> => {
+      return (
+        await this.client.indices.getAlias<estypes.IndicesGetAliasResponse>(
+          params,
+        )
+      ).body;
     },
     },
-    updateAliases: (params: RequestParams.IndicesUpdateAliases['body']): Promise<ApiResponse<estypes.IndicesUpdateAliasesResponse>> => {
+    updateAliases: (
+      params: RequestParams.IndicesUpdateAliases['body'],
+    ): Promise<ApiResponse<estypes.IndicesUpdateAliasesResponse>> => {
       return this.client.indices.updateAliases({ body: params });
       return this.client.indices.updateAliases({ body: params });
     },
     },
-    validateQuery: async(params: RequestParams.IndicesValidateQuery<{ query?: estypes.QueryDslQueryContainer }>)
-      : Promise<estypes.IndicesValidateQueryResponse> => {
-      return (await this.client.indices.validateQuery<estypes.IndicesValidateQueryResponse>(params)).body;
+    validateQuery: async (
+      params: RequestParams.IndicesValidateQuery<{
+        query?: estypes.QueryDslQueryContainer;
+      }>,
+    ): Promise<estypes.IndicesValidateQueryResponse> => {
+      return (
+        await this.client.indices.validateQuery<estypes.IndicesValidateQueryResponse>(
+          params,
+        )
+      ).body;
     },
     },
-    stats: async(params: RequestParams.IndicesStats): Promise<estypes.IndicesStatsResponse> => {
-      return (await this.client.indices.stats<estypes.IndicesStatsResponse>(params)).body;
+    stats: async (
+      params: RequestParams.IndicesStats,
+    ): Promise<estypes.IndicesStatsResponse> => {
+      return (
+        await this.client.indices.stats<estypes.IndicesStatsResponse>(params)
+      ).body;
     },
     },
   };
   };
 
 
   nodes = {
   nodes = {
-    info: (): Promise<ApiResponse<estypes.NodesInfoResponse>> => this.client.nodes.info(),
+    info: (): Promise<ApiResponse<estypes.NodesInfoResponse>> =>
+      this.client.nodes.info(),
   };
   };
 
 
   ping(): Promise<ApiResponse<estypes.PingResponse>> {
   ping(): Promise<ApiResponse<estypes.PingResponse>> {
     return this.client.ping();
     return this.client.ping();
   }
   }
 
 
-  reindex(indexName: string, tmpIndexName: string): Promise<ApiResponse<estypes.ReindexResponse>> {
-    return this.client.reindex({ wait_for_completion: false, body: { source: { index: indexName }, dest: { index: tmpIndexName } } });
+  reindex(
+    indexName: string,
+    tmpIndexName: string,
+  ): Promise<ApiResponse<estypes.ReindexResponse>> {
+    return this.client.reindex({
+      wait_for_completion: false,
+      body: { source: { index: indexName }, dest: { index: tmpIndexName } },
+    });
   }
   }
 
 
   async search(params: ES7SearchQuery): Promise<estypes.SearchResponse> {
   async search(params: ES7SearchQuery): Promise<estypes.SearchResponse> {
     return (await this.client.search<estypes.SearchResponse>(params)).body;
     return (await this.client.search<estypes.SearchResponse>(params)).body;
   }
   }
-
 }
 }

+ 58 - 17
apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es8-client-delegator.ts

@@ -1,7 +1,10 @@
-import { Client, type ClientOptions, type estypes } from '@elastic/elasticsearch8';
+import {
+  Client,
+  type ClientOptions,
+  type estypes,
+} from '@elastic/elasticsearch8';
 
 
 export class ES8ClientDelegator {
 export class ES8ClientDelegator {
-
   private client: Client;
   private client: Client;
 
 
   delegatorVersion = 8 as const;
   delegatorVersion = 8 as const;
@@ -15,24 +18,56 @@ export class ES8ClientDelegator {
   }
   }
 
 
   cat = {
   cat = {
-    aliases: (params: estypes.CatAliasesRequest): Promise<estypes.CatAliasesResponse> => this.client.cat.aliases(params),
-    indices: (params: estypes.CatIndicesRequest): Promise<estypes.CatIndicesResponse> => this.client.cat.indices(params),
+    aliases: (
+      params: estypes.CatAliasesRequest,
+    ): Promise<estypes.CatAliasesResponse> => this.client.cat.aliases(params),
+    indices: (
+      params: estypes.CatIndicesRequest,
+    ): Promise<estypes.CatIndicesResponse> => this.client.cat.indices(params),
   };
   };
 
 
   cluster = {
   cluster = {
-    health: (): Promise<estypes.ClusterHealthResponse> => this.client.cluster.health(),
+    health: (): Promise<estypes.ClusterHealthResponse> =>
+      this.client.cluster.health(),
   };
   };
 
 
   indices = {
   indices = {
-    create: (params: estypes.IndicesCreateRequest): Promise<estypes.IndicesCreateResponse> => this.client.indices.create(params),
-    delete: (params: estypes.IndicesDeleteRequest): Promise<estypes.IndicesDeleteResponse> => this.client.indices.delete(params),
-    exists: (params: estypes.IndicesExistsRequest): Promise<estypes.IndicesExistsResponse> => this.client.indices.exists(params),
-    existsAlias: (params: estypes.IndicesExistsAliasRequest): Promise<estypes.IndicesExistsAliasResponse> => this.client.indices.existsAlias(params),
-    putAlias: (params: estypes.IndicesPutAliasRequest): Promise<estypes.IndicesPutAliasResponse> => this.client.indices.putAlias(params),
-    getAlias: (params: estypes.IndicesGetAliasRequest): Promise<estypes.IndicesGetAliasResponse> => this.client.indices.getAlias(params),
-    updateAliases: (params: estypes.IndicesUpdateAliasesRequest): Promise<estypes.IndicesUpdateAliasesResponse> => this.client.indices.updateAliases(params),
-    validateQuery: (params: estypes.IndicesValidateQueryRequest): Promise<estypes.IndicesValidateQueryResponse> => this.client.indices.validateQuery(params),
-    stats: (params: estypes.IndicesStatsRequest): Promise<estypes.IndicesStatsResponse> => this.client.indices.stats(params),
+    create: (
+      params: estypes.IndicesCreateRequest,
+    ): Promise<estypes.IndicesCreateResponse> =>
+      this.client.indices.create(params),
+    delete: (
+      params: estypes.IndicesDeleteRequest,
+    ): Promise<estypes.IndicesDeleteResponse> =>
+      this.client.indices.delete(params),
+    exists: (
+      params: estypes.IndicesExistsRequest,
+    ): Promise<estypes.IndicesExistsResponse> =>
+      this.client.indices.exists(params),
+    existsAlias: (
+      params: estypes.IndicesExistsAliasRequest,
+    ): Promise<estypes.IndicesExistsAliasResponse> =>
+      this.client.indices.existsAlias(params),
+    putAlias: (
+      params: estypes.IndicesPutAliasRequest,
+    ): Promise<estypes.IndicesPutAliasResponse> =>
+      this.client.indices.putAlias(params),
+    getAlias: (
+      params: estypes.IndicesGetAliasRequest,
+    ): Promise<estypes.IndicesGetAliasResponse> =>
+      this.client.indices.getAlias(params),
+    updateAliases: (
+      params: estypes.IndicesUpdateAliasesRequest,
+    ): Promise<estypes.IndicesUpdateAliasesResponse> =>
+      this.client.indices.updateAliases(params),
+    validateQuery: (
+      params: estypes.IndicesValidateQueryRequest,
+    ): Promise<estypes.IndicesValidateQueryResponse> =>
+      this.client.indices.validateQuery(params),
+    stats: (
+      params: estypes.IndicesStatsRequest,
+    ): Promise<estypes.IndicesStatsResponse> =>
+      this.client.indices.stats(params),
   };
   };
 
 
   nodes = {
   nodes = {
@@ -43,12 +78,18 @@ export class ES8ClientDelegator {
     return this.client.ping();
     return this.client.ping();
   }
   }
 
 
-  reindex(indexName: string, tmpIndexName: string): Promise<estypes.ReindexResponse> {
-    return this.client.reindex({ wait_for_completion: false, source: { index: indexName }, dest: { index: tmpIndexName } });
+  reindex(
+    indexName: string,
+    tmpIndexName: string,
+  ): Promise<estypes.ReindexResponse> {
+    return this.client.reindex({
+      wait_for_completion: false,
+      source: { index: indexName },
+      dest: { index: tmpIndexName },
+    });
   }
   }
 
 
   search(params: estypes.SearchRequest): Promise<estypes.SearchResponse> {
   search(params: estypes.SearchRequest): Promise<estypes.SearchResponse> {
     return this.client.search(params);
     return this.client.search(params);
   }
   }
-
 }
 }

+ 58 - 17
apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es9-client-delegator.ts

@@ -1,7 +1,10 @@
-import { Client, type ClientOptions, type estypes } from '@elastic/elasticsearch9';
+import {
+  Client,
+  type ClientOptions,
+  type estypes,
+} from '@elastic/elasticsearch9';
 
 
 export class ES9ClientDelegator {
 export class ES9ClientDelegator {
-
   private client: Client;
   private client: Client;
 
 
   delegatorVersion = 9 as const;
   delegatorVersion = 9 as const;
@@ -15,24 +18,56 @@ export class ES9ClientDelegator {
   }
   }
 
 
   cat = {
   cat = {
-    aliases: (params: estypes.CatAliasesRequest): Promise<estypes.CatAliasesResponse> => this.client.cat.aliases(params),
-    indices: (params: estypes.CatIndicesRequest): Promise<estypes.CatIndicesResponse> => this.client.cat.indices(params),
+    aliases: (
+      params: estypes.CatAliasesRequest,
+    ): Promise<estypes.CatAliasesResponse> => this.client.cat.aliases(params),
+    indices: (
+      params: estypes.CatIndicesRequest,
+    ): Promise<estypes.CatIndicesResponse> => this.client.cat.indices(params),
   };
   };
 
 
   cluster = {
   cluster = {
-    health: (): Promise<estypes.ClusterHealthResponse> => this.client.cluster.health(),
+    health: (): Promise<estypes.ClusterHealthResponse> =>
+      this.client.cluster.health(),
   };
   };
 
 
   indices = {
   indices = {
-    create: (params: estypes.IndicesCreateRequest): Promise<estypes.IndicesCreateResponse> => this.client.indices.create(params),
-    delete: (params: estypes.IndicesDeleteRequest): Promise<estypes.IndicesDeleteResponse> => this.client.indices.delete(params),
-    exists: (params: estypes.IndicesExistsRequest): Promise<estypes.IndicesExistsResponse> => this.client.indices.exists(params),
-    existsAlias: (params: estypes.IndicesExistsAliasRequest): Promise<estypes.IndicesExistsAliasResponse> => this.client.indices.existsAlias(params),
-    putAlias: (params: estypes.IndicesPutAliasRequest): Promise<estypes.IndicesPutAliasResponse> => this.client.indices.putAlias(params),
-    getAlias: (params: estypes.IndicesGetAliasRequest): Promise<estypes.IndicesGetAliasResponse> => this.client.indices.getAlias(params),
-    updateAliases: (params: estypes.IndicesUpdateAliasesRequest): Promise<estypes.IndicesUpdateAliasesResponse> => this.client.indices.updateAliases(params),
-    validateQuery: (params: estypes.IndicesValidateQueryRequest): Promise<estypes.IndicesValidateQueryResponse> => this.client.indices.validateQuery(params),
-    stats: (params: estypes.IndicesStatsRequest): Promise<estypes.IndicesStatsResponse> => this.client.indices.stats(params),
+    create: (
+      params: estypes.IndicesCreateRequest,
+    ): Promise<estypes.IndicesCreateResponse> =>
+      this.client.indices.create(params),
+    delete: (
+      params: estypes.IndicesDeleteRequest,
+    ): Promise<estypes.IndicesDeleteResponse> =>
+      this.client.indices.delete(params),
+    exists: (
+      params: estypes.IndicesExistsRequest,
+    ): Promise<estypes.IndicesExistsResponse> =>
+      this.client.indices.exists(params),
+    existsAlias: (
+      params: estypes.IndicesExistsAliasRequest,
+    ): Promise<estypes.IndicesExistsAliasResponse> =>
+      this.client.indices.existsAlias(params),
+    putAlias: (
+      params: estypes.IndicesPutAliasRequest,
+    ): Promise<estypes.IndicesPutAliasResponse> =>
+      this.client.indices.putAlias(params),
+    getAlias: (
+      params: estypes.IndicesGetAliasRequest,
+    ): Promise<estypes.IndicesGetAliasResponse> =>
+      this.client.indices.getAlias(params),
+    updateAliases: (
+      params: estypes.IndicesUpdateAliasesRequest,
+    ): Promise<estypes.IndicesUpdateAliasesResponse> =>
+      this.client.indices.updateAliases(params),
+    validateQuery: (
+      params: estypes.IndicesValidateQueryRequest,
+    ): Promise<estypes.IndicesValidateQueryResponse> =>
+      this.client.indices.validateQuery(params),
+    stats: (
+      params: estypes.IndicesStatsRequest,
+    ): Promise<estypes.IndicesStatsResponse> =>
+      this.client.indices.stats(params),
   };
   };
 
 
   nodes = {
   nodes = {
@@ -43,12 +78,18 @@ export class ES9ClientDelegator {
     return this.client.ping();
     return this.client.ping();
   }
   }
 
 
-  reindex(indexName: string, tmpIndexName: string): Promise<estypes.ReindexResponse> {
-    return this.client.reindex({ wait_for_completion: false, source: { index: indexName }, dest: { index: tmpIndexName } });
+  reindex(
+    indexName: string,
+    tmpIndexName: string,
+  ): Promise<estypes.ReindexResponse> {
+    return this.client.reindex({
+      wait_for_completion: false,
+      source: { index: indexName },
+      dest: { index: tmpIndexName },
+    });
   }
   }
 
 
   search(params: estypes.SearchRequest): Promise<estypes.SearchResponse> {
   search(params: estypes.SearchRequest): Promise<estypes.SearchResponse> {
     return this.client.search(params);
     return this.client.search(params);
   }
   }
-
 }
 }

+ 44 - 31
apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/get-client.ts

@@ -2,55 +2,68 @@ import type { ClientOptions as ES7ClientOptions } from '@elastic/elasticsearch7'
 import type { ClientOptions as ES8ClientOptions } from '@elastic/elasticsearch8';
 import type { ClientOptions as ES8ClientOptions } from '@elastic/elasticsearch8';
 import type { ClientOptions as ES9ClientOptions } from '@elastic/elasticsearch9';
 import type { ClientOptions as ES9ClientOptions } from '@elastic/elasticsearch9';
 
 
-import { type ES7ClientDelegator } from './es7-client-delegator';
-import { type ES8ClientDelegator } from './es8-client-delegator';
-import { type ES9ClientDelegator } from './es9-client-delegator';
+import type { ES7ClientDelegator } from './es7-client-delegator';
+import type { ES8ClientDelegator } from './es8-client-delegator';
+import type { ES9ClientDelegator } from './es9-client-delegator';
 import type { ElasticsearchClientDelegator } from './interfaces';
 import type { ElasticsearchClientDelegator } from './interfaces';
 
 
-type GetDelegatorOptions = {
-  version: 7;
-  options: ES7ClientOptions;
-  rejectUnauthorized: boolean;
-} | {
-  version: 8;
-  options: ES8ClientOptions;
-  rejectUnauthorized: boolean;
-} | {
-  version: 9;
-  options: ES9ClientOptions;
-  rejectUnauthorized: boolean;
-}
+type GetDelegatorOptions =
+  | {
+      version: 7;
+      options: ES7ClientOptions;
+      rejectUnauthorized: boolean;
+    }
+  | {
+      version: 8;
+      options: ES8ClientOptions;
+      rejectUnauthorized: boolean;
+    }
+  | {
+      version: 9;
+      options: ES9ClientOptions;
+      rejectUnauthorized: boolean;
+    };
 
 
-type IsAny<T> = 'dummy' extends (T & 'dummy') ? true : false;
-type Delegator<Opts extends GetDelegatorOptions> =
-  IsAny<Opts> extends true
-    ? ElasticsearchClientDelegator
-    : Opts extends { version: 7 }
-      ? ES7ClientDelegator
-      : Opts extends { version: 8 }
-        ? ES8ClientDelegator
-        : Opts extends { version: 9 }
-          ? ES9ClientDelegator
-          : ElasticsearchClientDelegator
+type IsAny<T> = 'dummy' extends T & 'dummy' ? true : false;
+type Delegator<Opts extends GetDelegatorOptions> = IsAny<Opts> extends true
+  ? ElasticsearchClientDelegator
+  : Opts extends { version: 7 }
+    ? ES7ClientDelegator
+    : Opts extends { version: 8 }
+      ? ES8ClientDelegator
+      : Opts extends { version: 9 }
+        ? ES9ClientDelegator
+        : ElasticsearchClientDelegator;
 
 
 let instance: ElasticsearchClientDelegator | null = null;
 let instance: ElasticsearchClientDelegator | null = null;
-export const getClient = async<Opts extends GetDelegatorOptions>(opts: Opts): Promise<Delegator<Opts>> => {
+export const getClient = async <Opts extends GetDelegatorOptions>(
+  opts: Opts,
+): Promise<Delegator<Opts>> => {
   if (instance == null) {
   if (instance == null) {
     if (opts.version === 7) {
     if (opts.version === 7) {
       await import('./es7-client-delegator').then(({ ES7ClientDelegator }) => {
       await import('./es7-client-delegator').then(({ ES7ClientDelegator }) => {
-        instance = new ES7ClientDelegator(opts.options, opts.rejectUnauthorized);
+        instance = new ES7ClientDelegator(
+          opts.options,
+          opts.rejectUnauthorized,
+        );
         return instance;
         return instance;
       });
       });
     }
     }
     if (opts.version === 8) {
     if (opts.version === 8) {
       await import('./es8-client-delegator').then(({ ES8ClientDelegator }) => {
       await import('./es8-client-delegator').then(({ ES8ClientDelegator }) => {
-        instance = new ES8ClientDelegator(opts.options, opts.rejectUnauthorized);
+        instance = new ES8ClientDelegator(
+          opts.options,
+          opts.rejectUnauthorized,
+        );
         return instance;
         return instance;
       });
       });
     }
     }
     if (opts.version === 9) {
     if (opts.version === 9) {
       await import('./es9-client-delegator').then(({ ES9ClientDelegator }) => {
       await import('./es9-client-delegator').then(({ ES9ClientDelegator }) => {
-        instance = new ES9ClientDelegator(opts.options, opts.rejectUnauthorized);
+        instance = new ES9ClientDelegator(
+          opts.options,
+          opts.rejectUnauthorized,
+        );
         return instance;
         return instance;
       });
       });
     }
     }

+ 27 - 17
apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/interfaces.ts

@@ -1,4 +1,7 @@
-import type { estypes as ES7types, RequestParams } from '@elastic/elasticsearch7';
+import type {
+  estypes as ES7types,
+  RequestParams,
+} from '@elastic/elasticsearch7';
 import type { estypes as ES8types } from '@elastic/elasticsearch8';
 import type { estypes as ES8types } from '@elastic/elasticsearch8';
 import type { estypes as ES9types } from '@elastic/elasticsearch9';
 import type { estypes as ES9types } from '@elastic/elasticsearch9';
 
 
@@ -6,52 +9,59 @@ import type { ES7ClientDelegator } from './es7-client-delegator';
 import type { ES8ClientDelegator } from './es8-client-delegator';
 import type { ES8ClientDelegator } from './es8-client-delegator';
 import type { ES9ClientDelegator } from './es9-client-delegator';
 import type { ES9ClientDelegator } from './es9-client-delegator';
 
 
-export type ElasticsearchClientDelegator = ES7ClientDelegator | ES8ClientDelegator | ES9ClientDelegator;
-
+export type ElasticsearchClientDelegator =
+  | ES7ClientDelegator
+  | ES8ClientDelegator
+  | ES9ClientDelegator;
 
 
 // type guard
 // type guard
 // TODO: https://redmine.weseek.co.jp/issues/168446
 // TODO: https://redmine.weseek.co.jp/issues/168446
-export const isES7ClientDelegator = (delegator: ElasticsearchClientDelegator): delegator is ES7ClientDelegator => {
+export const isES7ClientDelegator = (
+  delegator: ElasticsearchClientDelegator,
+): delegator is ES7ClientDelegator => {
   return delegator.delegatorVersion === 7;
   return delegator.delegatorVersion === 7;
 };
 };
 
 
-export const isES8ClientDelegator = (delegator: ElasticsearchClientDelegator): delegator is ES8ClientDelegator => {
+export const isES8ClientDelegator = (
+  delegator: ElasticsearchClientDelegator,
+): delegator is ES8ClientDelegator => {
   return delegator.delegatorVersion === 8;
   return delegator.delegatorVersion === 8;
 };
 };
 
 
-export const isES9ClientDelegator = (delegator: ElasticsearchClientDelegator): delegator is ES9ClientDelegator => {
+export const isES9ClientDelegator = (
+  delegator: ElasticsearchClientDelegator,
+): delegator is ES9ClientDelegator => {
   return delegator.delegatorVersion === 9;
   return delegator.delegatorVersion === 9;
 };
 };
 
 
-
 // Official library-derived interface
 // Official library-derived interface
 // TODO: https://redmine.weseek.co.jp/issues/168446
 // TODO: https://redmine.weseek.co.jp/issues/168446
 export type ES7SearchQuery = RequestParams.Search<{
 export type ES7SearchQuery = RequestParams.Search<{
-  query: ES7types.QueryDslQueryContainer
-  sort?: ES7types.Sort
-  highlight?: ES7types.SearchHighlight
-}>
+  query: ES7types.QueryDslQueryContainer;
+  sort?: ES7types.Sort;
+  highlight?: ES7types.SearchHighlight;
+}>;
 
 
 export interface ES8SearchQuery {
 export interface ES8SearchQuery {
-  index: ES8types.IndexName
-  _source: ES8types.Fields
+  index: ES8types.IndexName;
+  _source: ES8types.Fields;
   from?: number;
   from?: number;
   size?: number;
   size?: number;
   body: {
   body: {
     query: ES8types.QueryDslQueryContainer;
     query: ES8types.QueryDslQueryContainer;
-    sort?: ES8types.Sort
+    sort?: ES8types.Sort;
     highlight?: ES8types.SearchHighlight;
     highlight?: ES8types.SearchHighlight;
   };
   };
 }
 }
 
 
 export interface ES9SearchQuery {
 export interface ES9SearchQuery {
-  index: ES9types.IndexName
-  _source: ES9types.Fields
+  index: ES9types.IndexName;
+  _source: ES9types.Fields;
   from?: number;
   from?: number;
   size?: number;
   size?: number;
   body: {
   body: {
     query: ES9types.QueryDslQueryContainer;
     query: ES9types.QueryDslQueryContainer;
-    sort?: ES9types.Sort
+    sort?: ES9types.Sort;
     highlight?: ES9types.SearchHighlight;
     highlight?: ES9types.SearchHighlight;
   };
   };
 }
 }

+ 248 - 127
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -1,10 +1,9 @@
-import { Writable, Transform } from 'stream';
-import { pipeline } from 'stream/promises';
-import { URL } from 'url';
-
 import { getIdStringForRef, type IPage } from '@growi/core';
 import { getIdStringForRef, type IPage } from '@growi/core';
 import gc from 'expose-gc/function';
 import gc from 'expose-gc/function';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
+import { Transform, Writable } from 'stream';
+import { pipeline } from 'stream/promises';
+import { URL } from 'url';
 
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import type { ISearchResult, ISearchResultData } from '~/interfaces/search';
 import type { ISearchResult, ISearchResultData } from '~/interfaces/search';
@@ -15,27 +14,34 @@ import type { SocketIoService } from '~/server/service/socket-io';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type {
 import type {
-  SearchDelegator, SearchableData, QueryTerms, UnavailableTermsKey, ESQueryTerms, ESTermsKey,
+  ESQueryTerms,
+  ESTermsKey,
+  QueryTerms,
+  SearchableData,
+  SearchDelegator,
+  UnavailableTermsKey,
 } from '../../interfaces/search';
 } from '../../interfaces/search';
 import type { PageModel } from '../../models/page';
 import type { PageModel } from '../../models/page';
 import { createBatchStream } from '../../util/batch-stream';
 import { createBatchStream } from '../../util/batch-stream';
 import { configManager } from '../config-manager';
 import { configManager } from '../config-manager';
 import type { UpdateOrInsertPagesOpts } from '../interfaces/search';
 import type { UpdateOrInsertPagesOpts } from '../interfaces/search';
-
 import { aggregatePipelineToIndex } from './aggregate-to-index';
 import { aggregatePipelineToIndex } from './aggregate-to-index';
 import type {
 import type {
-  AggregatedPage, BulkWriteBody, BulkWriteCommand, BulkWriteBodyRestriction,
+  AggregatedPage,
+  BulkWriteBody,
+  BulkWriteBodyRestriction,
+  BulkWriteCommand,
 } from './bulk-write';
 } from './bulk-write';
 import {
 import {
+  type ElasticsearchClientDelegator,
+  type ES7SearchQuery,
+  type ES8SearchQuery,
+  type ES9SearchQuery,
   getClient,
   getClient,
   isES7ClientDelegator,
   isES7ClientDelegator,
   isES8ClientDelegator,
   isES8ClientDelegator,
   isES9ClientDelegator,
   isES9ClientDelegator,
   type SearchQuery,
   type SearchQuery,
-  type ES7SearchQuery,
-  type ES8SearchQuery,
-  type ES9SearchQuery,
-  type ElasticsearchClientDelegator,
 } from './elasticsearch-client-delegator';
 } from './elasticsearch-client-delegator';
 
 
 const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
 const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
@@ -56,12 +62,22 @@ const ES_SORT_ORDER = {
   [ASC]: 'asc',
   [ASC]: 'asc',
 } as const;
 } as const;
 
 
-const AVAILABLE_KEYS = ['match', 'not_match', 'phrase', 'not_phrase', 'prefix', 'not_prefix', 'tag', 'not_tag'];
+const AVAILABLE_KEYS = [
+  'match',
+  'not_match',
+  'phrase',
+  'not_phrase',
+  'prefix',
+  'not_prefix',
+  'tag',
+  'not_tag',
+];
 
 
 type Data = any;
 type Data = any;
 
 
-class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQueryTerms> {
-
+class ElasticsearchDelegator
+  implements SearchDelegator<Data, ESTermsKey, ESQueryTerms>
+{
   name!: SearchDelegatorName.DEFAULT;
   name!: SearchDelegatorName.DEFAULT;
 
 
   private socketIoService!: SocketIoService;
   private socketIoService!: SocketIoService;
@@ -85,17 +101,27 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     this.name = SearchDelegatorName.DEFAULT;
     this.name = SearchDelegatorName.DEFAULT;
     this.socketIoService = socketIoService;
     this.socketIoService = socketIoService;
 
 
-    const elasticsearchVersion = configManager.getConfig('app:elasticsearchVersion');
+    const elasticsearchVersion = configManager.getConfig(
+      'app:elasticsearchVersion',
+    );
 
 
-    if (elasticsearchVersion !== 7 && elasticsearchVersion !== 8 && elasticsearchVersion !== 9) {
-      throw new Error('Unsupported Elasticsearch version. Please specify a valid number to \'ELASTICSEARCH_VERSION\'');
+    if (
+      elasticsearchVersion !== 7 &&
+      elasticsearchVersion !== 8 &&
+      elasticsearchVersion !== 9
+    ) {
+      throw new Error(
+        "Unsupported Elasticsearch version. Please specify a valid number to 'ELASTICSEARCH_VERSION'",
+      );
     }
     }
 
 
     this.isElasticsearchV7 = elasticsearchVersion === 7;
     this.isElasticsearchV7 = elasticsearchVersion === 7;
 
 
     this.elasticsearchVersion = elasticsearchVersion;
     this.elasticsearchVersion = elasticsearchVersion;
 
 
-    this.isElasticsearchReindexOnBoot = configManager.getConfig('app:elasticsearchReindexOnBoot');
+    this.isElasticsearchReindexOnBoot = configManager.getConfig(
+      'app:elasticsearchReindexOnBoot',
+    );
   }
   }
 
 
   /**
   /**
@@ -125,15 +151,23 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   async initClient(): Promise<void> {
   async initClient(): Promise<void> {
     const { host, auth, indexName } = this.getConnectionInfo();
     const { host, auth, indexName } = this.getConnectionInfo();
 
 
-    const rejectUnauthorized = configManager.getConfig('app:elasticsearchRejectUnauthorized');
+    const rejectUnauthorized = configManager.getConfig(
+      'app:elasticsearchRejectUnauthorized',
+    );
 
 
     const options = {
     const options = {
       node: host,
       node: host,
       auth,
       auth,
-      requestTimeout: configManager.getConfig('app:elasticsearchRequestTimeout'),
+      requestTimeout: configManager.getConfig(
+        'app:elasticsearchRequestTimeout',
+      ),
     };
     };
 
 
-    this.client = await getClient({ version: this.elasticsearchVersion, options, rejectUnauthorized });
+    this.client = await getClient({
+      version: this.elasticsearchVersion,
+      options,
+      rejectUnauthorized,
+    });
     this.indexName = indexName;
     this.indexName = indexName;
   }
   }
 
 
@@ -148,7 +182,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   getConnectionInfo() {
   getConnectionInfo() {
     let indexName = 'crowi';
     let indexName = 'crowi';
     let host: string | undefined;
     let host: string | undefined;
-    let auth;
+    let auth: { username: string; password: string } | undefined;
 
 
     const elasticsearchUri = configManager.getConfig('app:elasticsearchUri');
     const elasticsearchUri = configManager.getConfig('app:elasticsearchUri');
 
 
@@ -178,8 +212,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     if (this.isElasticsearchReindexOnBoot) {
     if (this.isElasticsearchReindexOnBoot) {
       try {
       try {
         await this.rebuildIndex();
         await this.rebuildIndex();
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Rebuild index on boot failed', err);
         logger.error('Rebuild index on boot failed', err);
       }
       }
       return;
       return;
@@ -245,12 +278,18 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
     // check existence
     // check existence
     const isExistsMainIndex = await client.indices.exists({ index: indexName });
     const isExistsMainIndex = await client.indices.exists({ index: indexName });
-    const isExistsTmpIndex = await client.indices.exists({ index: tmpIndexName });
+    const isExistsTmpIndex = await client.indices.exists({
+      index: tmpIndexName,
+    });
 
 
     // create indices name list
     // create indices name list
     const existingIndices: string[] = [];
     const existingIndices: string[] = [];
-    if (isExistsMainIndex) { existingIndices.push(indexName) }
-    if (isExistsTmpIndex) { existingIndices.push(tmpIndexName) }
+    if (isExistsMainIndex) {
+      existingIndices.push(indexName);
+    }
+    if (isExistsTmpIndex) {
+      existingIndices.push(tmpIndexName);
+    }
 
 
     // results when there is no indices
     // results when there is no indices
     if (existingIndices.length === 0) {
     if (existingIndices.length === 0) {
@@ -261,22 +300,34 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       };
       };
     }
     }
 
 
-    const indicesStats = await client.indices.stats({ index: existingIndices, metric: ['docs', 'store', 'indexing'] });
+    const indicesStats = await client.indices.stats({
+      index: existingIndices,
+      metric: ['docs', 'store', 'indexing'],
+    });
     const { indices } = indicesStats;
     const { indices } = indicesStats;
 
 
     const aliases = await client.indices.getAlias({ index: existingIndices });
     const aliases = await client.indices.getAlias({ index: existingIndices });
 
 
-    const isMainIndexHasAlias = isExistsMainIndex && aliases[indexName].aliases != null && aliases[indexName].aliases[aliasName] != null;
-    const isTmpIndexHasAlias = isExistsTmpIndex && aliases[tmpIndexName].aliases != null && aliases[tmpIndexName].aliases[aliasName] != null;
-
-    const isNormalized = isExistsMainIndex && isMainIndexHasAlias && !isExistsTmpIndex && !isTmpIndexHasAlias;
+    const isMainIndexHasAlias =
+      isExistsMainIndex &&
+      aliases[indexName].aliases != null &&
+      aliases[indexName].aliases[aliasName] != null;
+    const isTmpIndexHasAlias =
+      isExistsTmpIndex &&
+      aliases[tmpIndexName].aliases != null &&
+      aliases[tmpIndexName].aliases[aliasName] != null;
+
+    const isNormalized =
+      isExistsMainIndex &&
+      isMainIndexHasAlias &&
+      !isExistsTmpIndex &&
+      !isTmpIndexHasAlias;
 
 
     return {
     return {
       indices,
       indices,
       aliases,
       aliases,
       isNormalized,
       isNormalized,
     };
     };
-
   }
   }
 
 
   /**
   /**
@@ -306,21 +357,18 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       });
       });
       await this.createIndex(indexName);
       await this.createIndex(indexName);
       await this.addAllPages();
       await this.addAllPages();
-    }
-    catch (error) {
-      logger.error('An error occured while \'rebuildIndex\'.', error);
+    } catch (error) {
+      logger.error("An error occured while 'rebuildIndex'.", error);
       logger.error('error.meta.body', error?.meta?.body);
       logger.error('error.meta.body', error?.meta?.body);
 
 
       const socket = this.socketIoService.getAdminSocket();
       const socket = this.socketIoService.getAdminSocket();
       socket.emit(SocketEventName.RebuildingFailed, { error: error.message });
       socket.emit(SocketEventName.RebuildingFailed, { error: error.message });
 
 
       throw error;
       throw error;
-    }
-    finally {
+    } finally {
       logger.info('Normalize indices.');
       logger.info('Normalize indices.');
       await this.normalizeIndices();
       await this.normalizeIndices();
     }
     }
-
   }
   }
 
 
   async normalizeIndices(): Promise<void> {
   async normalizeIndices(): Promise<void> {
@@ -329,7 +377,9 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const tmpIndexName = `${indexName}-tmp`;
     const tmpIndexName = `${indexName}-tmp`;
 
 
     // remove tmp index
     // remove tmp index
-    const isExistsTmpIndex = await client.indices.exists({ index: tmpIndexName });
+    const isExistsTmpIndex = await client.indices.exists({
+      index: tmpIndexName,
+    });
     if (isExistsTmpIndex) {
     if (isExistsTmpIndex) {
       await client.indices.delete({ index: tmpIndexName });
       await client.indices.delete({ index: tmpIndexName });
     }
     }
@@ -341,7 +391,10 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     }
     }
 
 
     // create alias
     // create alias
-    const isExistsAlias = await client.indices.existsAlias({ name: aliasName, index: indexName });
+    const isExistsAlias = await client.indices.existsAlias({
+      name: aliasName,
+      index: indexName,
+    });
     if (!isExistsAlias) {
     if (!isExistsAlias) {
       await client.indices.putAlias({
       await client.indices.putAlias({
         name: aliasName,
         name: aliasName,
@@ -371,9 +424,10 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     }
     }
 
 
     if (isES9ClientDelegator(this.client)) {
     if (isES9ClientDelegator(this.client)) {
-      const { mappings } = process.env.CI == null
-        ? await import('./mappings/mappings-es9')
-        : await import('./mappings/mappings-es9-for-ci');
+      const { mappings } =
+        process.env.CI == null
+          ? await import('./mappings/mappings-es9')
+          : await import('./mappings/mappings-es9-for-ci');
 
 
       return this.client.indices.create({
       return this.client.indices.create({
         index,
         index,
@@ -385,9 +439,15 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   /**
   /**
    * generate object that is related to page.grant*
    * generate object that is related to page.grant*
    */
    */
-  generateDocContentsRelatedToRestriction(page: AggregatedPage): BulkWriteBodyRestriction {
-    const grantedUserIds = page.grantedUsers.map(user => getIdStringForRef(user));
-    const grantedGroupIds = page.grantedGroups.map(group => getIdStringForRef(group.item));
+  generateDocContentsRelatedToRestriction(
+    page: AggregatedPage,
+  ): BulkWriteBodyRestriction {
+    const grantedUserIds = page.grantedUsers.map((user) =>
+      getIdStringForRef(user),
+    );
+    const grantedGroupIds = page.grantedGroups.map((group) =>
+      getIdStringForRef(group.item),
+    );
 
 
     return {
     return {
       grant: page.grant,
       grant: page.grant,
@@ -396,8 +456,9 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     };
     };
   }
   }
 
 
-  prepareBodyForCreate(page: AggregatedPage): [BulkWriteCommand, BulkWriteBody] {
-
+  prepareBodyForCreate(
+    page: AggregatedPage,
+  ): [BulkWriteCommand, BulkWriteBody] {
     const command = {
     const command = {
       index: {
       index: {
         _index: this.indexName,
         _index: this.indexName,
@@ -443,7 +504,10 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
   addAllPages() {
   addAllPages() {
     const Page = this.getPageModel();
     const Page = this.getPageModel();
-    return this.updateOrInsertPages(() => Page.find(), { shouldEmitProgress: true, invokeGarbageCollection: true });
+    return this.updateOrInsertPages(() => Page.find(), {
+      shouldEmitProgress: true,
+      invokeGarbageCollection: true,
+    });
   }
   }
 
 
   updateOrInsertPageById(pageId) {
   updateOrInsertPageById(pageId) {
@@ -462,13 +526,19 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   /**
   /**
    * @param {function} queryFactory factory method to generate a Mongoose Query instance
    * @param {function} queryFactory factory method to generate a Mongoose Query instance
    */
    */
-  async updateOrInsertPages(queryFactory, option: UpdateOrInsertPagesOpts = {}): Promise<void> {
-    const { shouldEmitProgress = false, invokeGarbageCollection = false } = option;
+  async updateOrInsertPages(
+    queryFactory,
+    option: UpdateOrInsertPagesOpts = {},
+  ): Promise<void> {
+    const { shouldEmitProgress = false, invokeGarbageCollection = false } =
+      option;
 
 
     const Page = this.getPageModel();
     const Page = this.getPageModel();
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
 
 
-    const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null;
+    const socket = shouldEmitProgress
+      ? this.socketIoService.getAdminSocket()
+      : null;
 
 
     // prepare functions invoked from custom streams
     // prepare functions invoked from custom streams
     const prepareBodyForCreate = this.prepareBodyForCreate.bind(this);
     const prepareBodyForCreate = this.prepareBodyForCreate.bind(this);
@@ -479,26 +549,31 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const countQuery = new PageQueryBuilder(queryFactory()).query;
     const countQuery = new PageQueryBuilder(queryFactory()).query;
     const totalCount = await countQuery.count();
     const totalCount = await countQuery.count();
 
 
-    const maxBodyLengthToIndex = configManager.getConfig('app:elasticsearchMaxBodyLengthToIndex');
+    const maxBodyLengthToIndex = configManager.getConfig(
+      'app:elasticsearchMaxBodyLengthToIndex',
+    );
 
 
     const readStream = Page.aggregate<AggregatedPage>(
     const readStream = Page.aggregate<AggregatedPage>(
       aggregatePipelineToIndex(maxBodyLengthToIndex, matchQuery),
       aggregatePipelineToIndex(maxBodyLengthToIndex, matchQuery),
     ).cursor();
     ).cursor();
 
 
-    const bulkSize: number = configManager.getConfig('app:elasticsearchReindexBulkSize');
+    const bulkSize: number = configManager.getConfig(
+      'app:elasticsearchReindexBulkSize',
+    );
     const batchStream = createBatchStream(bulkSize);
     const batchStream = createBatchStream(bulkSize);
 
 
     const appendTagNamesStream = new Transform({
     const appendTagNamesStream = new Transform({
       objectMode: true,
       objectMode: true,
       async transform(chunk, encoding, callback) {
       async transform(chunk, encoding, callback) {
-        const pageIds = chunk.map(doc => doc._id);
+        const pageIds = chunk.map((doc) => doc._id);
 
 
-        const idToTagNamesMap = await PageTagRelation.getIdToTagNamesMap(pageIds);
+        const idToTagNamesMap =
+          await PageTagRelation.getIdToTagNamesMap(pageIds);
         const idsHavingTagNames = Object.keys(idToTagNamesMap);
         const idsHavingTagNames = Object.keys(idToTagNamesMap);
 
 
         // append tagNames
         // append tagNames
         chunk
         chunk
-          .filter(doc => idsHavingTagNames.includes(doc._id.toString()))
+          .filter((doc) => idsHavingTagNames.includes(doc._id.toString()))
           .forEach((doc: AggregatedPage) => {
           .forEach((doc: AggregatedPage) => {
             // append tagName from idToTagNamesMap
             // append tagName from idToTagNamesMap
             doc.tagNames = idToTagNamesMap[doc._id.toString()];
             doc.tagNames = idToTagNamesMap[doc._id.toString()];
@@ -513,7 +588,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const writeStream = new Writable({
     const writeStream = new Writable({
       objectMode: true,
       objectMode: true,
       async write(batch, encoding, callback) {
       async write(batch, encoding, callback) {
-        const body: (BulkWriteCommand|BulkWriteBody)[] = [];
+        const body: (BulkWriteCommand | BulkWriteBody)[] = [];
         batch.forEach((doc: AggregatedPage) => {
         batch.forEach((doc: AggregatedPage) => {
           body.push(...prepareBodyForCreate(doc));
           body.push(...prepareBodyForCreate(doc));
         });
         });
@@ -526,13 +601,17 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
           count += (bulkResponse.items || []).length;
           count += (bulkResponse.items || []).length;
 
 
-          logger.info(`Adding pages progressing: (count=${count}, errors=${bulkResponse.errors}, took=${bulkResponse.took}ms)`);
+          logger.info(
+            `Adding pages progressing: (count=${count}, errors=${bulkResponse.errors}, took=${bulkResponse.took}ms)`,
+          );
 
 
           if (shouldEmitProgress) {
           if (shouldEmitProgress) {
-            socket?.emit(SocketEventName.AddPageProgress, { totalCount, count });
+            socket?.emit(SocketEventName.AddPageProgress, {
+              totalCount,
+              count,
+            });
           }
           }
-        }
-        catch (err) {
+        } catch (err) {
           logger.error('addAllPages error on add anyway: ', err);
           logger.error('addAllPages error on add anyway: ', err);
         }
         }
 
 
@@ -541,8 +620,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
             // First aid to prevent unexplained memory leaks
             // First aid to prevent unexplained memory leaks
             logger.info('global.gc() invoked.');
             logger.info('global.gc() invoked.');
             gc();
             gc();
-          }
-          catch (err) {
+          } catch (err) {
             logger.error('fail garbage collection: ', err);
             logger.error('fail garbage collection: ', err);
           }
           }
         }
         }
@@ -559,18 +637,14 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       },
       },
     });
     });
 
 
-
-    return pipeline(
-      readStream,
-      batchStream,
-      appendTagNamesStream,
-      writeStream,
-    );
+    return pipeline(readStream, batchStream, appendTagNamesStream, writeStream);
   }
   }
 
 
   deletePages(pages) {
   deletePages(pages) {
     const body = [];
     const body = [];
-    pages.forEach(page => this.prepareBodyForDelete(body, page));
+    pages.forEach((page) => {
+      this.prepareBodyForDelete(body, page);
+    });
 
 
     logger.debug('deletePages(): Sending Request to ES', body);
     logger.debug('deletePages(): Sending Request to ES', body);
     return this.client.bulk({
     return this.client.bulk({
@@ -585,14 +659,14 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
    *   data: [ pages ...],
    *   data: [ pages ...],
    * }
    * }
    */
    */
-  async searchKeyword(query: SearchQuery): Promise<ISearchResult<ISearchResultData>> {
-
+  async searchKeyword(
+    query: SearchQuery,
+  ): Promise<ISearchResult<ISearchResultData>> {
     // for debug
     // for debug
     if (process.env.NODE_ENV === 'development') {
     if (process.env.NODE_ENV === 'development') {
       logger.debug('query: ', JSON.stringify(query, null, 2));
       logger.debug('query: ', JSON.stringify(query, null, 2));
 
 
-
-      const validateQueryResponse = await (async() => {
+      const validateQueryResponse = await (async () => {
         if (isES7ClientDelegator(this.client)) {
         if (isES7ClientDelegator(this.client)) {
           const es7SearchQuery = query as ES7SearchQuery;
           const es7SearchQuery = query as ES7SearchQuery;
           return this.client.indices.validateQuery({
           return this.client.indices.validateQuery({
@@ -625,12 +699,11 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
         throw new Error('Unsupported Elasticsearch version');
         throw new Error('Unsupported Elasticsearch version');
       })();
       })();
 
 
-
       // for debug
       // for debug
       logger.debug('ES result: ', validateQueryResponse);
       logger.debug('ES result: ', validateQueryResponse);
     }
     }
 
 
-    const searchResponse = await (async() => {
+    const searchResponse = await (async () => {
       if (isES7ClientDelegator(this.client)) {
       if (isES7ClientDelegator(this.client)) {
         return this.client.search(query as ES7SearchQuery);
         return this.client.search(query as ES7SearchQuery);
       }
       }
@@ -682,7 +755,15 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
    * @returns {object} query object
    * @returns {object} query object
    */
    */
   createSearchQuery(): SearchQuery {
   createSearchQuery(): SearchQuery {
-    const fields = ['path', 'bookmark_count', 'comment_count', 'seenUsers_count', 'updated_at', 'tag_names', 'comments'];
+    const fields = [
+      'path',
+      'bookmark_count',
+      'comment_count',
+      'seenUsers_count',
+      'updated_at',
+      'tag_names',
+      'comments',
+    ];
 
 
     // sort by score
     // sort by score
     const query: SearchQuery = {
     const query: SearchQuery = {
@@ -703,7 +784,11 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     query.size = size || DEFAULT_LIMIT;
     query.size = size || DEFAULT_LIMIT;
   }
   }
 
 
-  appendSortOrder(query: SearchQuery, sortAxis: SORT_AXIS, sortOrder: SORT_ORDER): void {
+  appendSortOrder(
+    query: SearchQuery,
+    sortAxis: SORT_AXIS,
+    sortOrder: SORT_ORDER,
+  ): void {
     if (query.body == null) {
     if (query.body == null) {
       throw new Error('query.body is not initialized');
       throw new Error('query.body is not initialized');
     }
     }
@@ -715,7 +800,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     query.body.sort = {
     query.body.sort = {
       [sort]: { order },
       [sort]: { order },
     };
     };
-
   }
   }
 
 
   initializeBoolQuery(query: SearchQuery): SearchQuery {
   initializeBoolQuery(query: SearchQuery): SearchQuery {
@@ -724,7 +808,9 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       throw new Error('query.body.query.bool is not initialized');
       throw new Error('query.body.query.bool is not initialized');
     }
     }
 
 
-    const isInitialized = (query) => { return !!query && Array.isArray(query) };
+    const isInitialized = (query) => {
+      return !!query && Array.isArray(query);
+    };
 
 
     if (!isInitialized(query.body.query.bool.filter)) {
     if (!isInitialized(query.body.query.bool.filter)) {
       query.body.query.bool.filter = [];
       query.body.query.bool.filter = [];
@@ -738,22 +824,34 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     return query;
     return query;
   }
   }
 
 
-  appendCriteriaForQueryString(query: SearchQuery, parsedKeywords: ESQueryTerms): void {
+  appendCriteriaForQueryString(
+    query: SearchQuery,
+    parsedKeywords: ESQueryTerms,
+  ): void {
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
 
 
     if (query.body?.query?.bool == null) {
     if (query.body?.query?.bool == null) {
       throw new Error('query.body.query.bool is not initialized');
       throw new Error('query.body.query.bool is not initialized');
     }
     }
 
 
-    if (query.body?.query?.bool.must == null || !Array.isArray(query.body?.query?.bool.must)) {
+    if (
+      query.body?.query?.bool.must == null ||
+      !Array.isArray(query.body?.query?.bool.must)
+    ) {
       throw new Error('query.body.query.bool.must is not initialized');
       throw new Error('query.body.query.bool.must is not initialized');
     }
     }
 
 
-    if (query.body?.query?.bool.must_not == null || !Array.isArray(query.body?.query?.bool.must_not)) {
+    if (
+      query.body?.query?.bool.must_not == null ||
+      !Array.isArray(query.body?.query?.bool.must_not)
+    ) {
       throw new Error('query.body.query.bool.must_not is not initialized');
       throw new Error('query.body.query.bool.must_not is not initialized');
     }
     }
 
 
-    if (query.body?.query?.bool.filter == null || !Array.isArray(query.body?.query?.bool.filter)) {
+    if (
+      query.body?.query?.bool.filter == null ||
+      !Array.isArray(query.body?.query?.bool.filter)
+    ) {
       throw new Error('query.body.query.bool.filter is not initialized');
       throw new Error('query.body.query.bool.filter is not initialized');
     }
     }
 
 
@@ -762,7 +860,14 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
         multi_match: {
         multi_match: {
           query: parsedKeywords.match.join(' '),
           query: parsedKeywords.match.join(' '),
           type: 'most_fields' as const,
           type: 'most_fields' as const,
-          fields: ['path.ja^2', 'path.en^2', 'body.ja', 'body.en', 'comments.ja', 'comments.en'],
+          fields: [
+            'path.ja^2',
+            'path.en^2',
+            'body.ja',
+            'body.en',
+            'comments.ja',
+            'comments.en',
+          ],
         },
         },
       };
       };
       query.body.query.bool.must.push(q);
       query.body.query.bool.must.push(q);
@@ -772,7 +877,14 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       const q = {
       const q = {
         multi_match: {
         multi_match: {
           query: parsedKeywords.not_match.join(' '),
           query: parsedKeywords.not_match.join(' '),
-          fields: ['path.ja', 'path.en', 'body.ja', 'body.en', 'comments.ja', 'comments.en'],
+          fields: [
+            'path.ja',
+            'path.en',
+            'body.ja',
+            'body.en',
+            'comments.ja',
+            'comments.en',
+          ],
           operator: 'or' as const,
           operator: 'or' as const,
         },
         },
       };
       };
@@ -843,32 +955,39 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     }
     }
   }
   }
 
 
-  async filterPagesByViewer(query: SearchQuery, user, userGroups): Promise<void> {
-    const showPagesRestrictedByOwner = !configManager.getConfig('security:list-policy:hideRestrictedByOwner');
-    const showPagesRestrictedByGroup = !configManager.getConfig('security:list-policy:hideRestrictedByGroup');
+  async filterPagesByViewer(
+    query: SearchQuery,
+    user,
+    userGroups,
+  ): Promise<void> {
+    const showPagesRestrictedByOwner = !configManager.getConfig(
+      'security:list-policy:hideRestrictedByOwner',
+    );
+    const showPagesRestrictedByGroup = !configManager.getConfig(
+      'security:list-policy:hideRestrictedByGroup',
+    );
 
 
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
 
 
-    if (query.body?.query?.bool?.filter == null || !Array.isArray(query.body?.query?.bool?.filter)) {
+    if (
+      query.body?.query?.bool?.filter == null ||
+      !Array.isArray(query.body?.query?.bool?.filter)
+    ) {
       throw new Error('query.body.query.bool is not initialized');
       throw new Error('query.body.query.bool is not initialized');
     }
     }
 
 
     const Page = this.getPageModel();
     const Page = this.getPageModel();
-    const {
-      GRANT_PUBLIC, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
-    } = Page;
+    const { GRANT_PUBLIC, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP } =
+      Page;
 
 
-    const grantConditions: any[] = [
-      { term: { grant: GRANT_PUBLIC } },
-    ];
+    const grantConditions: any[] = [{ term: { grant: GRANT_PUBLIC } }];
 
 
     if (showPagesRestrictedByOwner) {
     if (showPagesRestrictedByOwner) {
       grantConditions.push(
       grantConditions.push(
         { term: { grant: GRANT_SPECIFIED } },
         { term: { grant: GRANT_SPECIFIED } },
         { term: { grant: GRANT_OWNER } },
         { term: { grant: GRANT_OWNER } },
       );
       );
-    }
-    else if (user != null) {
+    } else if (user != null) {
       grantConditions.push(
       grantConditions.push(
         {
         {
           bool: {
           bool: {
@@ -890,22 +1009,19 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     }
     }
 
 
     if (showPagesRestrictedByGroup) {
     if (showPagesRestrictedByGroup) {
-      grantConditions.push(
-        { term: { grant: GRANT_USER_GROUP } },
-      );
-    }
-    else if (userGroups != null && userGroups.length > 0) {
-      const userGroupIds = userGroups.map((group) => { return group._id.toString() });
-      grantConditions.push(
-        {
-          bool: {
-            must: [
-              { term: { grant: GRANT_USER_GROUP } },
-              { terms: { granted_groups: userGroupIds } },
-            ],
-          },
+      grantConditions.push({ term: { grant: GRANT_USER_GROUP } });
+    } else if (userGroups != null && userGroups.length > 0) {
+      const userGroupIds = userGroups.map((group) => {
+        return group._id.toString();
+      });
+      grantConditions.push({
+        bool: {
+          must: [
+            { term: { grant: GRANT_USER_GROUP } },
+            { terms: { granted_groups: userGroupIds } },
+          ],
         },
         },
-      );
+      });
     }
     }
 
 
     query.body.query.bool.filter.push({ bool: { should: grantConditions } });
     query.body.query.bool.filter.push({ bool: { should: grantConditions } });
@@ -913,7 +1029,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
   async appendFunctionScore(query, queryString): Promise<void> {
   async appendFunctionScore(query, queryString): Promise<void> {
     const User = this.getUserModel();
     const User = this.getUserModel();
-    const count = await User.count({}) || 1;
+    const count = (await User.count({})) || 1;
 
 
     const minScore = queryString.length * 0.1 - 1; // increase with length
     const minScore = queryString.length * 0.1 - 1; // increase with length
     logger.debug('min_score: ', minScore);
     logger.debug('min_score: ', minScore);
@@ -958,7 +1074,12 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     };
     };
   }
   }
 
 
-  async search(data: SearchableData<ESQueryTerms>, user, userGroups, option?): Promise<ISearchResult<ISearchResultData>> {
+  async search(
+    data: SearchableData<ESQueryTerms>,
+    user,
+    userGroups,
+    option?,
+  ): Promise<ISearchResult<ISearchResultData>> {
     const { queryString, terms } = data;
     const { queryString, terms } = data;
 
 
     if (terms == null) {
     if (terms == null) {
@@ -976,7 +1097,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     await this.filterPagesByViewer(query, user, userGroups);
     await this.filterPagesByViewer(query, user, userGroups);
     await this.appendFunctionScore(query, queryString);
     await this.appendFunctionScore(query, queryString);
 
 
-
     this.appendResultSize(query, from, size);
     this.appendResultSize(query, from, size);
 
 
     this.appendSortOrder(query, sort, order);
     this.appendSortOrder(query, sort, order);
@@ -989,7 +1109,12 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   isTermsNormalized(terms: Partial<QueryTerms>): terms is ESQueryTerms {
   isTermsNormalized(terms: Partial<QueryTerms>): terms is ESQueryTerms {
     const entries = Object.entries(terms);
     const entries = Object.entries(terms);
 
 
-    return !entries.some(([key, val]) => !AVAILABLE_KEYS.includes(key) && typeof val?.length === 'number' && val.length > 0);
+    return !entries.some(
+      ([key, val]) =>
+        !AVAILABLE_KEYS.includes(key) &&
+        typeof val?.length === 'number' &&
+        val.length > 0,
+    );
   }
   }
 
 
   validateTerms(terms: QueryTerms): UnavailableTermsKey<ESTermsKey>[] {
   validateTerms(terms: QueryTerms): UnavailableTermsKey<ESTermsKey>[] {
@@ -1014,8 +1139,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       if (shoudDeletePages.length !== 0) {
       if (shoudDeletePages.length !== 0) {
         await this.deletePages(shoudDeletePages);
         await this.deletePages(shoudDeletePages);
       }
       }
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('deletePages:ES Error', err);
       logger.error('deletePages:ES Error', err);
     }
     }
   }
   }
@@ -1031,8 +1155,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
     try {
     try {
       return await this.deletePages(pages);
       return await this.deletePages(pages);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('deletePages:ES Error', err);
       logger.error('deletePages:ES Error', err);
     }
     }
   }
   }
@@ -1042,8 +1165,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
     try {
     try {
       return await this.deletePages([page]);
       return await this.deletePages([page]);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('deletePages:ES Error', err);
       logger.error('deletePages:ES Error', err);
     }
     }
   }
   }
@@ -1065,7 +1187,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
     return this.updateOrInsertPageById(page._id);
     return this.updateOrInsertPageById(page._id);
   }
   }
-
 }
 }
 
 
 export default ElasticsearchDelegator;
 export default ElasticsearchDelegator;

+ 48 - 21
apps/app/src/server/service/search-delegator/private-legacy-pages.ts

@@ -4,31 +4,46 @@ import mongoose from 'mongoose';
 
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import type { ISearchResult } from '~/interfaces/search';
 import type { ISearchResult } from '~/interfaces/search';
-import type { PageModel, PageDocument, PageQueryBuilder } from '~/server/models/page';
+import type {
+  PageDocument,
+  PageModel,
+  PageQueryBuilder,
+} from '~/server/models/page';
 import { serializePageSecurely } from '~/server/models/serializers';
 import { serializePageSecurely } from '~/server/models/serializers';
 
 
 import type {
 import type {
-  QueryTerms, MongoTermsKey,
-  SearchableData, SearchDelegator, UnavailableTermsKey, MongoQueryTerms,
+  MongoQueryTerms,
+  MongoTermsKey,
+  QueryTerms,
+  SearchableData,
+  SearchDelegator,
+  UnavailableTermsKey,
 } from '../../interfaces/search';
 } from '../../interfaces/search';
 
 
-
 const AVAILABLE_KEYS = ['match', 'not_match', 'prefix', 'not_prefix'];
 const AVAILABLE_KEYS = ['match', 'not_match', 'prefix', 'not_prefix'];
 
 
-class PrivateLegacyPagesDelegator implements SearchDelegator<IPage, MongoTermsKey, MongoQueryTerms> {
-
+class PrivateLegacyPagesDelegator
+  implements SearchDelegator<IPage, MongoTermsKey, MongoQueryTerms>
+{
   name!: SearchDelegatorName.PRIVATE_LEGACY_PAGES;
   name!: SearchDelegatorName.PRIVATE_LEGACY_PAGES;
 
 
   constructor() {
   constructor() {
     this.name = SearchDelegatorName.PRIVATE_LEGACY_PAGES;
     this.name = SearchDelegatorName.PRIVATE_LEGACY_PAGES;
   }
   }
 
 
-  async search(data: SearchableData<MongoQueryTerms>, user, userGroups, option): Promise<ISearchResult<IPage>> {
+  async search(
+    data: SearchableData<MongoQueryTerms>,
+    user,
+    userGroups,
+    option,
+  ): Promise<ISearchResult<IPage>> {
     const { terms } = data;
     const { terms } = data;
     const { offset, limit } = option;
     const { offset, limit } = option;
 
 
     if (offset == null || limit == null) {
     if (offset == null || limit == null) {
-      throw Error('PrivateLegacyPagesDelegator requires pagination options (offset, limit).');
+      throw Error(
+        'PrivateLegacyPagesDelegator requires pagination options (offset, limit).',
+      );
     }
     }
     if (user == null && userGroups == null) {
     if (user == null && userGroups == null) {
       throw Error('Either of user and userGroups must not be null.');
       throw Error('Either of user and userGroups must not be null.');
@@ -50,13 +65,12 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage, MongoTermsKe
 
 
     const pages: PageDocument[] = await findQueryBuilder
     const pages: PageDocument[] = await findQueryBuilder
       .addConditionToPagenate(offset, limit)
       .addConditionToPagenate(offset, limit)
-      .query
-      .populate('creator')
+      .query.populate('creator')
       .populate('lastUpdateUser')
       .populate('lastUpdateUser')
       .exec();
       .exec();
 
 
     return {
     return {
-      data: pages.map(page => serializePageSecurely(page)),
+      data: pages.map((page) => serializePageSecurely(page)),
       meta: {
       meta: {
         total,
         total,
         hitsCount: pages.length,
         hitsCount: pages.length,
@@ -64,22 +78,31 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage, MongoTermsKe
     };
     };
   }
   }
 
 
-  private addConditionByTerms(builder: PageQueryBuilder, terms: MongoQueryTerms): PageQueryBuilder {
-    const {
-      match, not_match: notMatch, prefix, not_prefix: notPrefix,
-    } = terms;
+  private addConditionByTerms(
+    builder: PageQueryBuilder,
+    terms: MongoQueryTerms,
+  ): PageQueryBuilder {
+    const { match, not_match: notMatch, prefix, not_prefix: notPrefix } = terms;
 
 
     if (match.length > 0) {
     if (match.length > 0) {
-      match.forEach(m => builder.addConditionToListByMatch(m));
+      for (const m of match) {
+        builder.addConditionToListByMatch(m);
+      }
     }
     }
     if (notMatch.length > 0) {
     if (notMatch.length > 0) {
-      notMatch.forEach(nm => builder.addConditionToListByNotMatch(nm));
+      for (const nm of notMatch) {
+        builder.addConditionToListByNotMatch(nm);
+      }
     }
     }
     if (prefix.length > 0) {
     if (prefix.length > 0) {
-      prefix.forEach(p => builder.addConditionToListByStartWith(p));
+      for (const p of prefix) {
+        builder.addConditionToListByStartWith(p);
+      }
     }
     }
     if (notPrefix.length > 0) {
     if (notPrefix.length > 0) {
-      notPrefix.forEach(np => builder.addConditionToListByNotStartWith(np));
+      for (const np of notPrefix) {
+        builder.addConditionToListByNotStartWith(np);
+      }
     }
     }
 
 
     return builder;
     return builder;
@@ -88,7 +111,12 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage, MongoTermsKe
   isTermsNormalized(terms: Partial<QueryTerms>): terms is MongoQueryTerms {
   isTermsNormalized(terms: Partial<QueryTerms>): terms is MongoQueryTerms {
     const entries = Object.entries(terms);
     const entries = Object.entries(terms);
 
 
-    return !entries.some(([key, val]) => !AVAILABLE_KEYS.includes(key) && typeof val?.length === 'number' && val.length > 0);
+    return !entries.some(
+      ([key, val]) =>
+        !AVAILABLE_KEYS.includes(key) &&
+        typeof val?.length === 'number' &&
+        val.length > 0,
+    );
   }
   }
 
 
   validateTerms(terms: QueryTerms): UnavailableTermsKey<MongoTermsKey>[] {
   validateTerms(terms: QueryTerms): UnavailableTermsKey<MongoTermsKey>[] {
@@ -98,7 +126,6 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage, MongoTermsKe
       .filter(([key, val]) => !AVAILABLE_KEYS.includes(key) && val.length > 0)
       .filter(([key, val]) => !AVAILABLE_KEYS.includes(key) && val.length > 0)
       .map(([key]) => key as UnavailableTermsKey<MongoTermsKey>); // use "as": https://github.com/microsoft/TypeScript/issues/41173
       .map(([key]) => key as UnavailableTermsKey<MongoTermsKey>); // use "as": https://github.com/microsoft/TypeScript/issues/41173
   }
   }
-
 }
 }
 
 
 export default PrivateLegacyPagesDelegator;
 export default PrivateLegacyPagesDelegator;

+ 6 - 5
apps/app/src/server/service/search-reconnect-context/reconnect-context.js

@@ -1,12 +1,12 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-const logger = loggerFactory('growi:service:search-reconnect-context:reconnect-context');
-
+const logger = loggerFactory(
+  'growi:service:search-reconnect-context:reconnect-context',
+);
 
 
 const RECONNECT_INTERVAL_SEC = 120;
 const RECONNECT_INTERVAL_SEC = 120;
 
 
 class ReconnectContext {
 class ReconnectContext {
-
   constructor() {
   constructor() {
     this.lastEvalDate = null;
     this.lastEvalDate = null;
 
 
@@ -39,7 +39,9 @@ class ReconnectContext {
       return true;
       return true;
     }
     }
 
 
-    const thres = this.lastEvalDate.setSeconds(this.lastEvalDate.getSeconds() + RECONNECT_INTERVAL_SEC);
+    const thres = this.lastEvalDate.setSeconds(
+      this.lastEvalDate.getSeconds() + RECONNECT_INTERVAL_SEC,
+    );
     return thres < new Date();
     return thres < new Date();
   }
   }
 
 
@@ -54,7 +56,6 @@ class ReconnectContext {
     }
     }
     return false;
     return false;
   }
   }
-
 }
 }
 
 
 async function nextTick(context, reconnectHandler) {
 async function nextTick(context, reconnectHandler) {

+ 266 - 119
apps/app/src/server/service/search.ts

@@ -4,26 +4,36 @@ import mongoose from 'mongoose';
 import { FilterXSS } from 'xss';
 import { FilterXSS } from 'xss';
 
 
 import { CommentEvent, commentEvent } from '~/features/comment/server';
 import { CommentEvent, commentEvent } from '~/features/comment/server';
-import { isIncludeAiMenthion, removeAiMenthion } from '~/features/search/utils/ai';
+import {
+  isIncludeAiMenthion,
+  removeAiMenthion,
+} from '~/features/search/utils/ai';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
-import type { IFormattedSearchResult, IPageWithSearchMeta, ISearchResult } from '~/interfaces/search';
+import type {
+  IFormattedSearchResult,
+  IPageWithSearchMeta,
+  ISearchResult,
+} from '~/interfaces/search';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../crowi';
 import type Crowi from '../crowi';
 import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import type {
 import type {
-  SearchDelegator, SearchQueryParser, SearchResolver, ParsedQuery, SearchableData, QueryTerms,
+  ParsedQuery,
+  QueryTerms,
+  SearchableData,
+  SearchDelegator,
+  SearchQueryParser,
+  SearchResolver,
 } from '../interfaces/search';
 } from '../interfaces/search';
 import NamedQuery from '../models/named-query';
 import NamedQuery from '../models/named-query';
 import type { PageModel } from '../models/page';
 import type { PageModel } from '../models/page';
 import { SearchError } from '../models/vo/search-error';
 import { SearchError } from '../models/vo/search-error';
 import { hasIntersection } from '../util/compare-objectId';
 import { hasIntersection } from '../util/compare-objectId';
-
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 import ElasticsearchDelegator from './search-delegator/elasticsearch';
 import ElasticsearchDelegator from './search-delegator/elasticsearch';
 import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages';
 import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages';
 
 
-
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:service:search');
 const logger = loggerFactory('growi:service:search');
 
 
@@ -41,8 +51,7 @@ const filterXss = new FilterXSS(filterXssOptions);
 
 
 const normalizeQueryString = (_queryString: string): string => {
 const normalizeQueryString = (_queryString: string): string => {
   let queryString = _queryString.trim();
   let queryString = _queryString.trim();
-  queryString = removeAiMenthion(queryString)
-    .replace(/\s+/g, ' ');
+  queryString = removeAiMenthion(queryString).replace(/\s+/g, ' ');
 
 
   return queryString;
   return queryString;
 };
 };
@@ -51,12 +60,14 @@ const normalizeNQName = (nqName: string): string => {
   return nqName.trim();
   return nqName.trim();
 };
 };
 
 
-const findPageListByIds = async(pageIds: ObjectIdLike[], crowi: any) => {
-
+const findPageListByIds = async (pageIds: ObjectIdLike[], crowi: any) => {
   const Page = crowi.model('Page') as unknown as PageModel;
   const Page = crowi.model('Page') as unknown as PageModel;
   const User = crowi.model('User');
   const User = crowi.model('User');
 
 
-  const builder = new Page.PageQueryBuilder(Page.find(({ _id: { $in: pageIds } })), false);
+  const builder = new Page.PageQueryBuilder(
+    Page.find({ _id: { $in: pageIds } }),
+    false,
+  );
 
 
   builder.addConditionToPagenate(undefined, undefined); // offset and limit are unnesessary
   builder.addConditionToPagenate(undefined, undefined); // offset and limit are unnesessary
 
 
@@ -73,11 +84,9 @@ const findPageListByIds = async(pageIds: ObjectIdLike[], crowi: any) => {
     pages,
     pages,
     totalCount,
     totalCount,
   };
   };
-
 };
 };
 
 
 class SearchService implements SearchQueryParser, SearchResolver {
 class SearchService implements SearchQueryParser, SearchResolver {
-
   crowi: Crowi;
   crowi: Crowi;
 
 
   isErrorOccuredOnHealthcheck: boolean | null;
   isErrorOccuredOnHealthcheck: boolean | null;
@@ -86,7 +95,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
 
   fullTextSearchDelegator: any & ElasticsearchDelegator;
   fullTextSearchDelegator: any & ElasticsearchDelegator;
 
 
-  nqDelegators: {[key in SearchDelegatorName]: SearchDelegator};
+  nqDelegators: { [key in SearchDelegatorName]: SearchDelegator };
 
 
   constructor(crowi: Crowi) {
   constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
@@ -96,10 +105,11 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
 
     try {
     try {
       this.fullTextSearchDelegator = this.generateFullTextSearchDelegator();
       this.fullTextSearchDelegator = this.generateFullTextSearchDelegator();
-      this.nqDelegators = this.generateNQDelegators(this.fullTextSearchDelegator);
+      this.nqDelegators = this.generateNQDelegators(
+        this.fullTextSearchDelegator,
+      );
       logger.info('Succeeded to initialize search delegators');
       logger.info('Succeeded to initialize search delegators');
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
     }
     }
 
 
@@ -114,7 +124,11 @@ class SearchService implements SearchQueryParser, SearchResolver {
   }
   }
 
 
   get isReachable() {
   get isReachable() {
-    return this.isConfigured && !this.isErrorOccuredOnHealthcheck && !this.isErrorOccuredOnSearching;
+    return (
+      this.isConfigured &&
+      !this.isErrorOccuredOnHealthcheck &&
+      !this.isErrorOccuredOnSearching
+    );
   }
   }
 
 
   get isElasticsearchEnabled() {
   get isElasticsearchEnabled() {
@@ -130,48 +144,130 @@ class SearchService implements SearchQueryParser, SearchResolver {
       return new ElasticsearchDelegator(this.crowi.socketIoService);
       return new ElasticsearchDelegator(this.crowi.socketIoService);
     }
     }
 
 
-    logger.info('No elasticsearch URI is specified so that full text search is disabled.');
+    logger.info(
+      'No elasticsearch URI is specified so that full text search is disabled.',
+    );
   }
   }
 
 
-  generateNQDelegators(defaultDelegator: ElasticsearchDelegator): {[key in SearchDelegatorName]: SearchDelegator} {
+  generateNQDelegators(defaultDelegator: ElasticsearchDelegator): {
+    [key in SearchDelegatorName]: SearchDelegator;
+  } {
     return {
     return {
       [SearchDelegatorName.DEFAULT]: defaultDelegator,
       [SearchDelegatorName.DEFAULT]: defaultDelegator,
-      [SearchDelegatorName.PRIVATE_LEGACY_PAGES]: new PrivateLegacyPagesDelegator() as unknown as SearchDelegator,
+      [SearchDelegatorName.PRIVATE_LEGACY_PAGES]:
+        new PrivateLegacyPagesDelegator() as unknown as SearchDelegator,
     };
     };
   }
   }
 
 
   registerUpdateEvent() {
   registerUpdateEvent() {
     const pageEvent = this.crowi.event('page');
     const pageEvent = this.crowi.event('page');
-    pageEvent.on('create', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
-    pageEvent.on('update', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
+    pageEvent.on(
+      'create',
+      this.fullTextSearchDelegator.syncPageUpdated.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+    pageEvent.on(
+      'update',
+      this.fullTextSearchDelegator.syncPageUpdated.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
     pageEvent.on('delete', (targetPage, deletedPage, user) => {
     pageEvent.on('delete', (targetPage, deletedPage, user) => {
-      this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator)(targetPage, user);
-      this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator)(deletedPage, user);
+      this.fullTextSearchDelegator.syncPageDeleted.bind(
+        this.fullTextSearchDelegator,
+      )(targetPage, user);
+      this.fullTextSearchDelegator.syncPageUpdated.bind(
+        this.fullTextSearchDelegator,
+      )(deletedPage, user);
     });
     });
     pageEvent.on('revert', (targetPage, revertedPage, user) => {
     pageEvent.on('revert', (targetPage, revertedPage, user) => {
-      this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator)(targetPage, user);
-      this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator)(revertedPage, user);
+      this.fullTextSearchDelegator.syncPageDeleted.bind(
+        this.fullTextSearchDelegator,
+      )(targetPage, user);
+      this.fullTextSearchDelegator.syncPageUpdated.bind(
+        this.fullTextSearchDelegator,
+      )(revertedPage, user);
     });
     });
-    pageEvent.on('deleteCompletely', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
-    pageEvent.on('syncDescendantsDelete', this.fullTextSearchDelegator.syncDescendantsPagesDeleted.bind(this.fullTextSearchDelegator));
-    pageEvent.on('updateMany', this.fullTextSearchDelegator.syncPagesUpdated.bind(this.fullTextSearchDelegator));
-    pageEvent.on('syncDescendantsUpdate', this.fullTextSearchDelegator.syncDescendantsPagesUpdated.bind(this.fullTextSearchDelegator));
-    pageEvent.on('addSeenUsers', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
+    pageEvent.on(
+      'deleteCompletely',
+      this.fullTextSearchDelegator.syncPageDeleted.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+    pageEvent.on(
+      'syncDescendantsDelete',
+      this.fullTextSearchDelegator.syncDescendantsPagesDeleted.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+    pageEvent.on(
+      'updateMany',
+      this.fullTextSearchDelegator.syncPagesUpdated.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+    pageEvent.on(
+      'syncDescendantsUpdate',
+      this.fullTextSearchDelegator.syncDescendantsPagesUpdated.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+    pageEvent.on(
+      'addSeenUsers',
+      this.fullTextSearchDelegator.syncPageUpdated.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
     pageEvent.on('rename', () => {
     pageEvent.on('rename', () => {
-      this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator);
-      this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator);
+      this.fullTextSearchDelegator.syncPageDeleted.bind(
+        this.fullTextSearchDelegator,
+      );
+      this.fullTextSearchDelegator.syncPageUpdated.bind(
+        this.fullTextSearchDelegator,
+      );
     });
     });
 
 
     const bookmarkEvent = this.crowi.event('bookmark');
     const bookmarkEvent = this.crowi.event('bookmark');
-    bookmarkEvent.on('create', this.fullTextSearchDelegator.syncBookmarkChanged.bind(this.fullTextSearchDelegator));
-    bookmarkEvent.on('delete', this.fullTextSearchDelegator.syncBookmarkChanged.bind(this.fullTextSearchDelegator));
+    bookmarkEvent.on(
+      'create',
+      this.fullTextSearchDelegator.syncBookmarkChanged.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+    bookmarkEvent.on(
+      'delete',
+      this.fullTextSearchDelegator.syncBookmarkChanged.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
 
 
     const tagEvent = this.crowi.event('tag');
     const tagEvent = this.crowi.event('tag');
-    tagEvent.on('update', this.fullTextSearchDelegator.syncTagChanged.bind(this.fullTextSearchDelegator));
-
-    commentEvent.on(CommentEvent.CREATE, this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
-    commentEvent.on(CommentEvent.UPDATE, this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
-    commentEvent.on(CommentEvent.DELETE, this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
+    tagEvent.on(
+      'update',
+      this.fullTextSearchDelegator.syncTagChanged.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+
+    commentEvent.on(
+      CommentEvent.CREATE,
+      this.fullTextSearchDelegator.syncCommentChanged.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+    commentEvent.on(
+      CommentEvent.UPDATE,
+      this.fullTextSearchDelegator.syncCommentChanged.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
+    commentEvent.on(
+      CommentEvent.DELETE,
+      this.fullTextSearchDelegator.syncCommentChanged.bind(
+        this.fullTextSearchDelegator,
+      ),
+    );
   }
   }
 
 
   resetErrorStatus() {
   resetErrorStatus() {
@@ -188,8 +284,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
 
       logger.info('Reconnecting succeeded.');
       logger.info('Reconnecting succeeded.');
       this.resetErrorStatus();
       this.resetErrorStatus();
-    }
-    catch (err) {
+    } catch (err) {
       throw err;
       throw err;
     }
     }
   }
   }
@@ -197,8 +292,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
   async getInfo() {
   async getInfo() {
     try {
     try {
       return await this.fullTextSearchDelegator.getInfo();
       return await this.fullTextSearchDelegator.getInfo();
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       throw err;
       throw err;
     }
     }
@@ -210,8 +304,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
 
       this.isErrorOccuredOnHealthcheck = false;
       this.isErrorOccuredOnHealthcheck = false;
       return result;
       return result;
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
 
 
       // switch error flag, `isErrorOccuredOnHealthcheck` to be `false`
       // switch error flag, `isErrorOccuredOnHealthcheck` to be `false`
@@ -232,7 +325,10 @@ class SearchService implements SearchQueryParser, SearchResolver {
     return this.fullTextSearchDelegator.rebuildIndex();
     return this.fullTextSearchDelegator.rebuildIndex();
   }
   }
 
 
-  async parseSearchQuery(queryString: string, nqName: string | null): Promise<ParsedQuery> {
+  async parseSearchQuery(
+    queryString: string,
+    nqName: string | null,
+  ): Promise<ParsedQuery> {
     // eslint-disable-next-line no-param-reassign
     // eslint-disable-next-line no-param-reassign
     queryString = normalizeQueryString(queryString);
     queryString = normalizeQueryString(queryString);
 
 
@@ -246,7 +342,9 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
 
     // will delegate to full-text search
     // will delegate to full-text search
     if (nq == null) {
     if (nq == null) {
-      logger.debug(`Delegated to full-text search since a named query document did not found. (nqName="${nqName}")`);
+      logger.debug(
+        `Delegated to full-text search since a named query document did not found. (nqName="${nqName}")`,
+      );
       return { queryString, terms };
       return { queryString, terms };
     }
     }
 
 
@@ -254,17 +352,25 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
 
     let parsedQuery: ParsedQuery;
     let parsedQuery: ParsedQuery;
     if (aliasOf != null) {
     if (aliasOf != null) {
-      parsedQuery = { queryString: normalizeQueryString(aliasOf), terms: this.parseQueryString(aliasOf) };
-    }
-    else {
+      parsedQuery = {
+        queryString: normalizeQueryString(aliasOf),
+        terms: this.parseQueryString(aliasOf),
+      };
+    } else {
       parsedQuery = { queryString, terms, delegatorName };
       parsedQuery = { queryString, terms, delegatorName };
     }
     }
 
 
     return parsedQuery;
     return parsedQuery;
   }
   }
 
 
-  async resolve(parsedQuery: ParsedQuery): Promise<[SearchDelegator, SearchableData]> {
-    const { queryString, terms, delegatorName = SearchDelegatorName.DEFAULT } = parsedQuery;
+  async resolve(
+    parsedQuery: ParsedQuery,
+  ): Promise<[SearchDelegator, SearchableData]> {
+    const {
+      queryString,
+      terms,
+      delegatorName = SearchDelegatorName.DEFAULT,
+    } = parsedQuery;
     const nqDeledator = this.nqDelegators[delegatorName];
     const nqDeledator = this.nqDelegators[delegatorName];
 
 
     const data = {
     const data = {
@@ -280,7 +386,10 @@ class SearchService implements SearchQueryParser, SearchResolver {
    * @param {SearchDelegator} delegator
    * @param {SearchDelegator} delegator
    * @throws {SearchError} SearchError
    * @throws {SearchError} SearchError
    */
    */
-  private validateSearchableData(delegator: SearchDelegator, data: SearchableData): void {
+  private validateSearchableData(
+    delegator: SearchDelegator,
+    data: SearchableData,
+  ): void {
     const { terms } = data;
     const { terms } = data;
 
 
     if (delegator.isTermsNormalized(terms)) {
     if (delegator.isTermsNormalized(terms)) {
@@ -289,16 +398,24 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
 
     const unavailableTermsKeys = delegator.validateTerms(terms);
     const unavailableTermsKeys = delegator.validateTerms(terms);
 
 
-    throw new SearchError('The query string includes unavailable terms.', unavailableTermsKeys);
+    throw new SearchError(
+      'The query string includes unavailable terms.',
+      unavailableTermsKeys,
+    );
   }
   }
 
 
-  async searchKeyword(keyword: string, nqName: string | null, user, userGroups, searchOpts): Promise<[ISearchResult<unknown>, string | null]> {
+  async searchKeyword(
+    keyword: string,
+    nqName: string | null,
+    user,
+    userGroups,
+    searchOpts,
+  ): Promise<[ISearchResult<unknown>, string | null]> {
     let parsedQuery: ParsedQuery;
     let parsedQuery: ParsedQuery;
     // parse
     // parse
     try {
     try {
       parsedQuery = await this.parseSearchQuery(keyword, nqName);
       parsedQuery = await this.parseSearchQuery(keyword, nqName);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Error occurred while parseSearchQuery', err);
       logger.error('Error occurred while parseSearchQuery', err);
       throw err;
       throw err;
     }
     }
@@ -312,8 +429,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
     // resolve
     // resolve
     try {
     try {
       [delegator, data] = await this.resolve(parsedQuery);
       [delegator, data] = await this.resolve(parsedQuery);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Error occurred while resolving search delegator', err);
       logger.error('Error occurred while resolving search delegator', err);
       throw err;
       throw err;
     }
     }
@@ -321,7 +437,10 @@ class SearchService implements SearchQueryParser, SearchResolver {
     // throws
     // throws
     this.validateSearchableData(delegator, data);
     this.validateSearchableData(delegator, data);
 
 
-    return [await delegator.search(data, user, userGroups, searchOpts), delegator.name ?? null];
+    return [
+      await delegator.search(data, user, userGroups, searchOpts),
+      delegator.name ?? null,
+    ];
   }
   }
 
 
   parseQueryString(queryString: string): QueryTerms {
   parseQueryString(queryString: string): QueryTerms {
@@ -346,8 +465,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
         phrase.trim();
         phrase.trim();
         if (phrase.match(/^-/)) {
         if (phrase.match(/^-/)) {
           notPhraseWords.push(phrase.replace(/^-/, ''));
           notPhraseWords.push(phrase.replace(/^-/, ''));
-        }
-        else {
+        } else {
           phraseWords.push(phrase);
           phraseWords.push(phrase);
         }
         }
       });
       });
@@ -367,22 +485,17 @@ class SearchService implements SearchQueryParser, SearchResolver {
       if (matchNegative != null) {
       if (matchNegative != null) {
         if (matchNegative[1] === 'prefix:') {
         if (matchNegative[1] === 'prefix:') {
           notPrefixPaths.push(matchNegative[2]);
           notPrefixPaths.push(matchNegative[2]);
-        }
-        else if (matchNegative[1] === 'tag:') {
+        } else if (matchNegative[1] === 'tag:') {
           notTags.push(matchNegative[2]);
           notTags.push(matchNegative[2]);
-        }
-        else {
+        } else {
           notMatchWords.push(matchNegative[2]);
           notMatchWords.push(matchNegative[2]);
         }
         }
-      }
-      else if (matchPositive != null) {
+      } else if (matchPositive != null) {
         if (matchPositive[1] === 'prefix:') {
         if (matchPositive[1] === 'prefix:') {
           prefixPaths.push(matchPositive[2]);
           prefixPaths.push(matchPositive[2]);
-        }
-        else if (matchPositive[1] === 'tag:') {
+        } else if (matchPositive[1] === 'tag:') {
           tags.push(matchPositive[2]);
           tags.push(matchPositive[2]);
-        }
-        else {
+        } else {
           matchWords.push(matchPositive[2]);
           matchWords.push(matchPositive[2]);
         }
         }
       }
       }
@@ -404,14 +517,22 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
 
   // TODO: optimize the way to check isFormattable e.g. check data schema of searchResult
   // TODO: optimize the way to check isFormattable e.g. check data schema of searchResult
   // So far, it determines by delegatorName passed by searchService.searchKeyword
   // So far, it determines by delegatorName passed by searchService.searchKeyword
-  checkIsFormattable(searchResult, delegatorName: SearchDelegatorName): boolean {
+  checkIsFormattable(
+    searchResult,
+    delegatorName: SearchDelegatorName,
+  ): boolean {
     return delegatorName === SearchDelegatorName.DEFAULT;
     return delegatorName === SearchDelegatorName.DEFAULT;
   }
   }
 
 
   /**
   /**
    * formatting result
    * formatting result
    */
    */
-  async formatSearchResult(searchResult: ISearchResult<any>, delegatorName: SearchDelegatorName, user, userGroups): Promise<IFormattedSearchResult> {
+  async formatSearchResult(
+    searchResult: ISearchResult<any>,
+    delegatorName: SearchDelegatorName,
+    user,
+    userGroups,
+  ): Promise<IFormattedSearchResult> {
     if (!this.checkIsFormattable(searchResult, delegatorName)) {
     if (!this.checkIsFormattable(searchResult, delegatorName)) {
       const data: IPageWithSearchMeta[] = searchResult.data.map((page) => {
       const data: IPageWithSearchMeta[] = searchResult.data.map((page) => {
         return {
         return {
@@ -432,7 +553,9 @@ class SearchService implements SearchQueryParser, SearchResolver {
     const result = {} as IFormattedSearchResult;
     const result = {} as IFormattedSearchResult;
 
 
     // get page data
     // get page data
-    const pageIds: string[] = searchResult.data.map((page) => { return page._id });
+    const pageIds: string[] = searchResult.data.map((page) => {
+      return page._id;
+    });
 
 
     const findPageResult = await findPageListByIds(pageIds, this.crowi);
     const findPageResult = await findPageListByIds(pageIds, this.crowi);
 
 
@@ -440,53 +563,75 @@ class SearchService implements SearchQueryParser, SearchResolver {
     result.meta = searchResult.meta;
     result.meta = searchResult.meta;
 
 
     // set search result page data
     // set search result page data
-    const pages: (IPageWithSearchMeta | null)[] = searchResult.data.map((data) => {
-      const pageData = findPageResult.pages.find((pageData) => {
-        return pageData.id === data._id;
-      });
+    const pages: (IPageWithSearchMeta | null)[] = searchResult.data.map(
+      (data) => {
+        const pageData = findPageResult.pages.find((pageData) => {
+          return pageData.id === data._id;
+        });
+
+        if (pageData == null) {
+          return null;
+        }
 
 
-      if (pageData == null) {
-        return null;
-      }
+        // add tags and seenUserCount to pageData
+        pageData._doc.tags = data._source.tag_names;
+        pageData._doc.seenUserCount =
+          (pageData.seenUsers && pageData.seenUsers.length) || 0;
+
+        // serialize lastUpdateUser
+        if (
+          pageData.lastUpdateUser != null &&
+          pageData.lastUpdateUser instanceof User
+        ) {
+          pageData.lastUpdateUser = serializeUserSecurely(
+            pageData.lastUpdateUser,
+          );
+        }
 
 
-      // add tags and seenUserCount to pageData
-      pageData._doc.tags = data._source.tag_names;
-      pageData._doc.seenUserCount = (pageData.seenUsers && pageData.seenUsers.length) || 0;
+        // increment elasticSearchResult
+        let elasticSearchResult:
+          | { snippet: string | null; highlightedPath: string | null }
+          | undefined;
+        const highlightData = data._highlight;
+        if (highlightData != null) {
+          const snippet = this.canShowSnippet(pageData, user, userGroups)
+            ? // eslint-disable-next-line max-len
+              highlightData.body ||
+              highlightData['body.en'] ||
+              highlightData['body.ja'] ||
+              highlightData.comments ||
+              highlightData['comments.en'] ||
+              highlightData['comments.ja']
+            : null;
+          const pathMatch =
+            highlightData['path.en'] || highlightData['path.ja'];
+
+          elasticSearchResult = {
+            snippet:
+              snippet != null && typeof snippet[0] === 'string'
+                ? filterXss.process(snippet)
+                : null,
+            highlightedPath:
+              pathMatch != null && typeof pathMatch[0] === 'string'
+                ? filterXss.process(pathMatch)
+                : null,
+          };
+        }
 
 
-      // serialize lastUpdateUser
-      if (pageData.lastUpdateUser != null && pageData.lastUpdateUser instanceof User) {
-        pageData.lastUpdateUser = serializeUserSecurely(pageData.lastUpdateUser);
-      }
+        // serialize creator
+        if (pageData.creator != null && pageData.creator instanceof User) {
+          pageData.creator = serializeUserSecurely(pageData.creator);
+        }
 
 
-      // increment elasticSearchResult
-      let elasticSearchResult;
-      const highlightData = data._highlight;
-      if (highlightData != null) {
-        const snippet = this.canShowSnippet(pageData, user, userGroups)
-          // eslint-disable-next-line max-len
-          ? highlightData.body || highlightData['body.en'] || highlightData['body.ja'] || highlightData.comments || highlightData['comments.en'] || highlightData['comments.ja']
-          : null;
-        const pathMatch = highlightData['path.en'] || highlightData['path.ja'];
-
-        elasticSearchResult = {
-          snippet: snippet != null && typeof snippet[0] === 'string' ? filterXss.process(snippet) : null,
-          highlightedPath: pathMatch != null && typeof pathMatch[0] === 'string' ? filterXss.process(pathMatch) : null,
+        // generate pageMeta data
+        const pageMeta = {
+          bookmarkCount: data._source.bookmark_count || 0,
+          elasticSearchResult,
         };
         };
-      }
 
 
-      // serialize creator
-      if (pageData.creator != null && pageData.creator instanceof User) {
-        pageData.creator = serializeUserSecurely(pageData.creator);
-      }
-
-      // generate pageMeta data
-      const pageMeta = {
-        bookmarkCount: data._source.bookmark_count || 0,
-        elasticSearchResult,
-      };
-
-      return { data: pageData, meta: pageMeta };
-    });
+        return { data: pageData, meta: pageMeta };
+      },
+    );
 
 
     result.data = pages.filter(nonNullable);
     result.data = pages.filter(nonNullable);
     return result;
     return result;
@@ -512,12 +657,14 @@ class SearchService implements SearchQueryParser, SearchResolver {
     if (testGrant === Page.GRANT_USER_GROUP) {
     if (testGrant === Page.GRANT_USER_GROUP) {
       if (userGroups == null) return false;
       if (userGroups == null) return false;
 
 
-      return hasIntersection(userGroups.map(id => id.toString()), testGrantedGroups);
+      return hasIntersection(
+        userGroups.map((id) => id.toString()),
+        testGrantedGroups,
+      );
     }
     }
 
 
     return true;
     return true;
   }
   }
-
 }
 }
 
 
 export default SearchService;
 export default SearchService;

+ 18 - 6
apps/app/src/server/service/slack-command-handler/create-page-service.js

@@ -14,7 +14,6 @@ const { pathUtils } = require('@growi/core/dist/utils');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 
 
 class CreatePageService {
 class CreatePageService {
-
   /** @type {import('~/server/crowi').default} Crowi instance */
   /** @type {import('~/server/crowi').default} Crowi instance */
   crowi;
   crowi;
 
 
@@ -23,7 +22,13 @@ class CreatePageService {
     this.crowi = crowi;
     this.crowi = crowi;
   }
   }
 
 
-  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil, user) {
+  async createPageInGrowi(
+    interactionPayloadAccessor,
+    path,
+    contentsBody,
+    respondUtil,
+    user,
+  ) {
     const reshapedContentsBody = reshapeContentsBody(contentsBody);
     const reshapedContentsBody = reshapeContentsBody(contentsBody);
 
 
     // sanitize path
     // sanitize path
@@ -31,20 +36,27 @@ class CreatePageService {
     const normalizedPath = pathUtils.normalizePath(sanitizedPath);
     const normalizedPath = pathUtils.normalizePath(sanitizedPath);
 
 
     // Since an ObjectId is required for creating a page, if a user does not exist, a dummy user will be generated
     // Since an ObjectId is required for creating a page, if a user does not exist, a dummy user will be generated
-    const userOrDummyUser = user != null ? user : { _id: new mongoose.Types.ObjectId() };
+    const userOrDummyUser =
+      user != null ? user : { _id: new mongoose.Types.ObjectId() };
 
 
-    const page = await this.crowi.pageService.create(normalizedPath, reshapedContentsBody, userOrDummyUser, {});
+    const page = await this.crowi.pageService.create(
+      normalizedPath,
+      reshapedContentsBody,
+      userOrDummyUser,
+      {},
+    );
 
 
     // Send a message when page creation is complete
     // Send a message when page creation is complete
     const growiUri = growiInfoService.getSiteUrl();
     const growiUri = growiInfoService.getSiteUrl();
     await respondUtil.respond({
     await respondUtil.respond({
       text: 'Page has been created',
       text: 'Page has been created',
       blocks: [
       blocks: [
-        markdownSectionBlock(`The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`),
+        markdownSectionBlock(
+          `The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`,
+        ),
       ],
       ],
     });
     });
   }
   }
-
 }
 }
 
 
 module.exports = CreatePageService;
 module.exports = CreatePageService;

+ 35 - 15
apps/app/src/server/service/slack-command-handler/error-handler.ts

@@ -1,29 +1,36 @@
-import assert from 'assert';
-
 import type { RespondBodyForResponseUrl } from '@growi/slack';
 import type { RespondBodyForResponseUrl } from '@growi/slack';
 import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder';
 import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder';
 import { respond } from '@growi/slack/dist/utils/response-url';
 import { respond } from '@growi/slack/dist/utils/response-url';
 import { type ChatPostEphemeralResponse, WebClient } from '@slack/web-api';
 import { type ChatPostEphemeralResponse, WebClient } from '@slack/web-api';
-
+import assert from 'assert';
 
 
 import { SlackCommandHandlerError } from '../../models/vo/slack-command-handler-error';
 import { SlackCommandHandlerError } from '../../models/vo/slack-command-handler-error';
 
 
-function generateRespondBodyForInternalServerError(message): RespondBodyForResponseUrl {
+function generateRespondBodyForInternalServerError(
+  message,
+): RespondBodyForResponseUrl {
   return {
   return {
     text: message,
     text: message,
     blocks: [
     blocks: [
-      markdownSectionBlock(`*GROWI Internal Server Error occured.*\n \`${message}\``),
+      markdownSectionBlock(
+        `*GROWI Internal Server Error occured.*\n \`${message}\``,
+      ),
     ],
     ],
   };
   };
 }
 }
 
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-async function handleErrorWithWebClient(error: Error, client: WebClient, body: any): Promise<ChatPostEphemeralResponse> {
-
+async function handleErrorWithWebClient(
+  error: Error,
+  client: WebClient,
+  body: any,
+): Promise<ChatPostEphemeralResponse> {
   const isInteraction = !body.channel_id;
   const isInteraction = !body.channel_id;
 
 
   // this method is expected to use when system couldn't response_url
   // this method is expected to use when system couldn't response_url
-  assert(!(error instanceof SlackCommandHandlerError) || error.responseUrl == null);
+  assert(
+    !(error instanceof SlackCommandHandlerError) || error.responseUrl == null,
+  );
 
 
   const payload = JSON.parse(body.payload);
   const payload = JSON.parse(body.payload);
 
 
@@ -37,15 +44,23 @@ async function handleErrorWithWebClient(error: Error, client: WebClient, body: a
   });
   });
 }
 }
 
 
-
-export async function handleError(error: SlackCommandHandlerError | Error, responseUrl?: string): Promise<void>;
+export async function handleError(
+  error: SlackCommandHandlerError | Error,
+  responseUrl?: string,
+): Promise<void>;
 
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-export async function handleError(error: Error, client: WebClient, body: any): Promise<ChatPostEphemeralResponse>;
+export async function handleError(
+  error: Error,
+  client: WebClient,
+  body: any,
+): Promise<ChatPostEphemeralResponse>;
 
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-export async function handleError(error: SlackCommandHandlerError | Error, ...args: any[]): Promise<void|ChatPostEphemeralResponse> {
-
+export async function handleError(
+  error: SlackCommandHandlerError | Error,
+  ...args: any[]
+): Promise<void | ChatPostEphemeralResponse> {
   // handle a SlackCommandHandlerError
   // handle a SlackCommandHandlerError
   if (error instanceof SlackCommandHandlerError) {
   if (error instanceof SlackCommandHandlerError) {
     const responseUrl = args[0] || error.responseUrl;
     const responseUrl = args[0] || error.responseUrl;
@@ -56,11 +71,16 @@ export async function handleError(error: SlackCommandHandlerError | Error, ...ar
   }
   }
 
 
   const secondArg = args[0];
   const secondArg = args[0];
-  assert(secondArg != null, 'Couldn\'t handle Error without the second argument.');
+  assert(
+    secondArg != null,
+    "Couldn't handle Error without the second argument.",
+  );
 
 
   // handle a normal Error with response_url
   // handle a normal Error with response_url
   if (typeof secondArg === 'string') {
   if (typeof secondArg === 'string') {
-    const respondBody = generateRespondBodyForInternalServerError(error.message);
+    const respondBody = generateRespondBodyForInternalServerError(
+      error.message,
+    );
     return respond(secondArg, respondBody);
     return respond(secondArg, respondBody);
   }
   }
 
 

+ 6 - 6
apps/app/src/server/service/slack-command-handler/help.js

@@ -12,22 +12,22 @@ module.exports = (crowi) => {
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
   const handler = new BaseSlackCommandHandler();
 
 
-  handler.handleCommand = async(growiCommand, client, body, respondUtil) => {
+  handler.handleCommand = async (growiCommand, client, body, respondUtil) => {
     const appTitle = crowi.appService.getAppTitle();
     const appTitle = crowi.appService.getAppTitle();
     const appSiteUrl = growiInfoService.getSiteUrl();
     const appSiteUrl = growiInfoService.getSiteUrl();
     // adjust spacing
     // adjust spacing
     let message = `*Help* (*${appTitle}* at ${appSiteUrl})\n\n`;
     let message = `*Help* (*${appTitle}* at ${appSiteUrl})\n\n`;
     message += 'Usage:     `/growi [command] [args]`\n\n';
     message += 'Usage:     `/growi [command] [args]`\n\n';
     message += 'Commands:\n\n';
     message += 'Commands:\n\n';
-    message += '`/growi note`                          Take a note on GROWI\n\n';
+    message +=
+      '`/growi note`                          Take a note on GROWI\n\n';
     message += '`/growi search [keyword]`       Search pages\n\n';
     message += '`/growi search [keyword]`       Search pages\n\n';
-    message += '`/growi keep`                          Create new page with existing slack conversations (Alpha)\n\n';
+    message +=
+      '`/growi keep`                          Create new page with existing slack conversations (Alpha)\n\n';
 
 
     await respondUtil.respond({
     await respondUtil.respond({
       text: 'Help',
       text: 'Help',
-      blocks: [
-        markdownSectionBlock(message),
-      ],
+      blocks: [markdownSectionBlock(message)],
     });
     });
   };
   };
 
 

+ 156 - 62
apps/app/src/server/service/slack-command-handler/keep.js

@@ -1,5 +1,8 @@
 import {
 import {
-  inputBlock, actionsBlock, buttonElement, markdownSectionBlock,
+  actionsBlock,
+  buttonElement,
+  inputBlock,
+  markdownSectionBlock,
 } from '@growi/slack/dist/utils/block-kit-builder';
 } from '@growi/slack/dist/utils/block-kit-builder';
 import { format } from 'date-fns/format';
 import { format } from 'date-fns/format';
 import { parse } from 'date-fns/parse';
 import { parse } from 'date-fns/parse';
@@ -17,7 +20,12 @@ module.exports = (crowi) => {
   const handler = new BaseSlackCommandHandler();
   const handler = new BaseSlackCommandHandler();
   const { User } = crowi.models;
   const { User } = crowi.models;
 
 
-  handler.handleCommand = async function(growiCommand, client, body, respondUtil) {
+  handler.handleCommand = async function (
+    growiCommand,
+    client,
+    body,
+    respondUtil,
+  ) {
     await respondUtil.respond({
     await respondUtil.respond({
       text: 'Select messages to use.',
       text: 'Select messages to use.',
       blocks: this.keepMessageBlocks(body.channel_name),
       blocks: this.keepMessageBlocks(body.channel_name),
@@ -25,21 +33,46 @@ module.exports = (crowi) => {
     return;
     return;
   };
   };
 
 
-  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) {
-    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil);
+  handler.handleInteractions = async function (
+    client,
+    interactionPayload,
+    interactionPayloadAccessor,
+    handlerMethodName,
+    respondUtil,
+  ) {
+    await this[handlerMethodName](
+      client,
+      interactionPayload,
+      interactionPayloadAccessor,
+      respondUtil,
+    );
   };
   };
 
 
-  handler.cancel = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+  handler.cancel = async (
+    client,
+    payload,
+    interactionPayloadAccessor,
+    respondUtil,
+  ) => {
     await respondUtil.deleteOriginal();
     await respondUtil.deleteOriginal();
   };
   };
 
 
-  handler.createPage = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+  handler.createPage = async function (
+    client,
+    payload,
+    interactionPayloadAccessor,
+    respondUtil,
+  ) {
     let result = [];
     let result = [];
     const channelId = payload.channel.id; // this must exist since the type is always block_actions
     const channelId = payload.channel.id; // this must exist since the type is always block_actions
     const user = await User.findUserBySlackMemberId(payload.user.id);
     const user = await User.findUserBySlackMemberId(payload.user.id);
 
 
     // validate form
     // validate form
-    const { path, oldest, newest } = await this.keepValidateForm(client, payload, interactionPayloadAccessor);
+    const { path, oldest, newest } = await this.keepValidateForm(
+      client,
+      payload,
+      interactionPayloadAccessor,
+    );
     // get messages
     // get messages
     result = await this.keepGetMessages(client, channelId, newest, oldest);
     result = await this.keepGetMessages(client, channelId, newest, oldest);
     // clean messages
     // clean messages
@@ -47,37 +80,66 @@ module.exports = (crowi) => {
 
 
     const contentsBody = cleanedContents.join('');
     const contentsBody = cleanedContents.join('');
     // create and send url message
     // create and send url message
-    await this.keepCreatePageAndSendPreview(client, interactionPayloadAccessor, path, user, contentsBody, respondUtil);
+    await this.keepCreatePageAndSendPreview(
+      client,
+      interactionPayloadAccessor,
+      path,
+      user,
+      contentsBody,
+      respondUtil,
+    );
   };
   };
 
 
-  handler.keepValidateForm = async function(client, payload, interactionPayloadAccessor) {
+  handler.keepValidateForm = async (
+    client,
+    payload,
+    interactionPayloadAccessor,
+  ) => {
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
-    const path = interactionPayloadAccessor.getStateValues()?.page_path.page_path.value;
-    let oldest = interactionPayloadAccessor.getStateValues()?.oldest.oldest.value;
-    let newest = interactionPayloadAccessor.getStateValues()?.newest.newest.value;
+    const path =
+      interactionPayloadAccessor.getStateValues()?.page_path.page_path.value;
+    let oldest =
+      interactionPayloadAccessor.getStateValues()?.oldest.oldest.value;
+    let newest =
+      interactionPayloadAccessor.getStateValues()?.newest.newest.value;
 
 
     if (oldest == null || newest == null || path == null) {
     if (oldest == null || newest == null || path == null) {
-      throw new SlackCommandHandlerError('All parameters are required. (Oldest datetime, Newst datetime and Page path)');
+      throw new SlackCommandHandlerError(
+        'All parameters are required. (Oldest datetime, Newst datetime and Page path)',
+      );
     }
     }
 
 
     /**
     /**
      * RegExp for datetime yyyy/MM/dd-HH:mm
      * RegExp for datetime yyyy/MM/dd-HH:mm
      * @see https://regex101.com/r/XbxdNo/1
      * @see https://regex101.com/r/XbxdNo/1
      */
      */
-    const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/);
+    const regexpDatetime = new RegExp(
+      /^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/,
+    );
 
 
     if (!regexpDatetime.test(oldest.trim())) {
     if (!regexpDatetime.test(oldest.trim())) {
-      throw new SlackCommandHandlerError('Datetime format for oldest must be yyyy/MM/dd-HH:mm');
+      throw new SlackCommandHandlerError(
+        'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
+      );
     }
     }
     if (!regexpDatetime.test(newest.trim())) {
     if (!regexpDatetime.test(newest.trim())) {
-      throw new SlackCommandHandlerError('Datetime format for newest must be yyyy/MM/dd-HH:mm');
+      throw new SlackCommandHandlerError(
+        'Datetime format for newest must be yyyy/MM/dd-HH:mm',
+      );
     }
     }
-    oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
+    oldest =
+      parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 +
+      grwTzoffset;
     // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
     // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
-    newest = parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
+    newest =
+      parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 +
+      grwTzoffset +
+      60;
 
 
     if (oldest > newest) {
     if (oldest > newest) {
-      throw new SlackCommandHandlerError('Oldest datetime must be older than the newest date time.');
+      throw new SlackCommandHandlerError(
+        'Oldest datetime must be older than the newest date time.',
+      );
     }
     }
 
 
     return { path, oldest, newest };
     return { path, oldest, newest };
@@ -93,14 +155,13 @@ module.exports = (crowi) => {
     });
     });
   }
   }
 
 
-  handler.keepGetMessages = async function(client, channelId, newest, oldest) {
+  handler.keepGetMessages = async (client, channelId, newest, oldest) => {
     let result;
     let result;
 
 
     // first attempt
     // first attempt
     try {
     try {
       result = await retrieveHistory(client, channelId, newest, oldest);
       result = await retrieveHistory(client, channelId, newest, oldest);
-    }
-    catch (err) {
+    } catch (err) {
       const errorCode = err.data?.errorCode;
       const errorCode = err.data?.errorCode;
 
 
       if (errorCode === 'not_in_channel') {
       if (errorCode === 'not_in_channel') {
@@ -109,12 +170,11 @@ module.exports = (crowi) => {
           channel: channelId,
           channel: channelId,
         });
         });
         result = await retrieveHistory(client, channelId, newest, oldest);
         result = await retrieveHistory(client, channelId, newest, oldest);
-      }
-      else if (errorCode === 'channel_not_found') {
-
-        const message = ':cry: GROWI Bot couldn\'t get history data because *this channel was private*.'
-          + '\nPlease add GROWI bot to this channel.'
-          + '\n';
+      } else if (errorCode === 'channel_not_found') {
+        const message =
+          ":cry: GROWI Bot couldn't get history data because *this channel was private*." +
+          '\nPlease add GROWI bot to this channel.' +
+          '\n';
         throw new SlackCommandHandlerError(message, {
         throw new SlackCommandHandlerError(message, {
           respondBody: {
           respondBody: {
             text: message,
             text: message,
@@ -122,21 +182,23 @@ module.exports = (crowi) => {
               markdownSectionBlock(message),
               markdownSectionBlock(message),
               {
               {
                 type: 'image',
                 type: 'image',
-                image_url: 'https://user-images.githubusercontent.com/1638767/135658794-a8d2dbc8-580f-4203-b368-e74e2f3c7b3a.png',
+                image_url:
+                  'https://user-images.githubusercontent.com/1638767/135658794-a8d2dbc8-580f-4203-b368-e74e2f3c7b3a.png',
                 alt_text: 'Add app to this channel',
                 alt_text: 'Add app to this channel',
               },
               },
             ],
             ],
           },
           },
         });
         });
-      }
-      else {
+      } else {
         throw err;
         throw err;
       }
       }
     }
     }
 
 
     // return if no message found
     // return if no message found
     if (result.messages.length === 0) {
     if (result.messages.length === 0) {
-      throw new SlackCommandHandlerError('No message found from keep command. Try different datetime.');
+      throw new SlackCommandHandlerError(
+        'No message found from keep command. Try different datetime.',
+      );
     }
     }
     return result;
     return result;
   };
   };
@@ -146,7 +208,7 @@ module.exports = (crowi) => {
    * @param {*} messages (array of messages)
    * @param {*} messages (array of messages)
    * @returns users object with matching Slack Member ID
    * @returns users object with matching Slack Member ID
    */
    */
-  handler.getGrowiUsersFromMessages = async function(messages) {
+  handler.getGrowiUsersFromMessages = async (messages) => {
     const users = messages.map((message) => {
     const users = messages.map((message) => {
       return message.user;
       return message.user;
     });
     });
@@ -157,21 +219,22 @@ module.exports = (crowi) => {
    * Convert slack member ID to growi user if slack member ID is found in messages
    * Convert slack member ID to growi user if slack member ID is found in messages
    * @param {*} messages
    * @param {*} messages
    */
    */
-  handler.injectGrowiUsernameToMessages = async function(messages) {
+  handler.injectGrowiUsernameToMessages = async function (messages) {
     const growiUsers = await this.getGrowiUsersFromMessages(messages);
     const growiUsers = await this.getGrowiUsersFromMessages(messages);
 
 
-    messages.map(async(message) => {
-      const growiUser = growiUsers.find(user => user.slackMemberId === message.user);
+    messages.map(async (message) => {
+      const growiUser = growiUsers.find(
+        (user) => user.slackMemberId === message.user,
+      );
       if (growiUser != null) {
       if (growiUser != null) {
         message.user = `${growiUser.name} (@${growiUser.username})`;
         message.user = `${growiUser.name} (@${growiUser.username})`;
-      }
-      else {
+      } else {
         message.user = `This slack member ID is not registered (${message.user})`;
         message.user = `This slack member ID is not registered (${message.user})`;
       }
       }
     });
     });
   };
   };
 
 
-  handler.keepCleanMessages = async function(messages) {
+  handler.keepCleanMessages = async function (messages) {
     const cleanedContents = [];
     const cleanedContents = [];
     let lastMessage = {};
     let lastMessage = {};
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
@@ -199,8 +262,21 @@ module.exports = (crowi) => {
     return cleanedContents;
     return cleanedContents;
   };
   };
 
 
-  handler.keepCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, user, contentsBody, respondUtil) {
-    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil, user);
+  handler.keepCreatePageAndSendPreview = async (
+    client,
+    interactionPayloadAccessor,
+    path,
+    user,
+    contentsBody,
+    respondUtil,
+  ) => {
+    await createPageService.createPageInGrowi(
+      interactionPayloadAccessor,
+      path,
+      contentsBody,
+      respondUtil,
+      user,
+    );
 
 
     // TODO: contentsBody text characters must be less than 3001
     // TODO: contentsBody text characters must be less than 3001
     // send preview to dm
     // send preview to dm
@@ -219,7 +295,7 @@ module.exports = (crowi) => {
     await respondUtil.deleteOriginal();
     await respondUtil.deleteOriginal();
   };
   };
 
 
-  handler.keepMessageBlocks = function(channelName) {
+  handler.keepMessageBlocks = (channelName) => {
     const tzDateSec = new Date().getTime();
     const tzDateSec = new Date().getTime();
     const grwTzoffset = crowi.appService.getTzoffset() * 60 * 1000;
     const grwTzoffset = crowi.appService.getTzoffset() * 60 * 1000;
 
 
@@ -233,29 +309,47 @@ module.exports = (crowi) => {
 
 
     return [
     return [
       markdownSectionBlock('*The keep command is in alpha.*'),
       markdownSectionBlock('*The keep command is in alpha.*'),
-      markdownSectionBlock('Select the oldest and newest datetime of the messages to use.'),
-      inputBlock({
-        type: 'plain_text_input',
-        action_id: 'oldest',
-        initial_value: initialOldest,
-      }, 'oldest', 'Oldest datetime'),
-      inputBlock({
-        type: 'plain_text_input',
-        action_id: 'newest',
-        initial_value: initialNewest,
-      }, 'newest', 'Newest datetime'),
-      inputBlock({
-        type: 'plain_text_input',
-        placeholder: {
-          type: 'plain_text',
-          text: 'Input page path to create.',
+      markdownSectionBlock(
+        'Select the oldest and newest datetime of the messages to use.',
+      ),
+      inputBlock(
+        {
+          type: 'plain_text_input',
+          action_id: 'oldest',
+          initial_value: initialOldest,
         },
         },
-        initial_value: initialPagePath,
-        action_id: 'page_path',
-      }, 'page_path', 'Page path'),
+        'oldest',
+        'Oldest datetime',
+      ),
+      inputBlock(
+        {
+          type: 'plain_text_input',
+          action_id: 'newest',
+          initial_value: initialNewest,
+        },
+        'newest',
+        'Newest datetime',
+      ),
+      inputBlock(
+        {
+          type: 'plain_text_input',
+          placeholder: {
+            type: 'plain_text',
+            text: 'Input page path to create.',
+          },
+          initial_value: initialPagePath,
+          action_id: 'page_path',
+        },
+        'page_path',
+        'Page path',
+      ),
       actionsBlock(
       actionsBlock(
         buttonElement({ text: 'Cancel', actionId: 'keep:cancel' }),
         buttonElement({ text: 'Cancel', actionId: 'keep:cancel' }),
-        buttonElement({ text: 'Create page', actionId: 'keep:createPage', style: 'primary' }),
+        buttonElement({
+          text: 'Create page',
+          actionId: 'keep:createPage',
+          style: 'primary',
+        }),
       ),
       ),
     ];
     ];
   };
   };

+ 60 - 13
apps/app/src/server/service/slack-command-handler/note.js

@@ -1,5 +1,9 @@
 import {
 import {
-  markdownHeaderBlock, inputSectionBlock, inputBlock, actionsBlock, buttonElement,
+  actionsBlock,
+  buttonElement,
+  inputBlock,
+  inputSectionBlock,
+  markdownHeaderBlock,
 } from '@growi/slack/dist/utils/block-kit-builder';
 } from '@growi/slack/dist/utils/block-kit-builder';
 
 
 import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
 import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
@@ -20,39 +24,82 @@ module.exports = (crowi) => {
   };
   };
   const { User } = crowi.models;
   const { User } = crowi.models;
 
 
-  handler.handleCommand = async(growiCommand, client, body, respondUtil) => {
+  handler.handleCommand = async (growiCommand, client, body, respondUtil) => {
     await respondUtil.respond({
     await respondUtil.respond({
       text: 'Take a note on GROWI',
       text: 'Take a note on GROWI',
       blocks: [
       blocks: [
         markdownHeaderBlock('Take a note on GROWI'),
         markdownHeaderBlock('Take a note on GROWI'),
-        inputBlock(conversationsSelectElement, 'conversation', 'Channel name to display in the page to be created'),
+        inputBlock(
+          conversationsSelectElement,
+          'conversation',
+          'Channel name to display in the page to be created',
+        ),
         inputSectionBlock('path', 'Page path', 'path_input', false, '/path'),
         inputSectionBlock('path', 'Page path', 'path_input', false, '/path'),
-        inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
+        inputSectionBlock(
+          'contents',
+          'Contents',
+          'contents_input',
+          true,
+          'Input with Markdown...',
+        ),
         actionsBlock(
         actionsBlock(
           buttonElement({ text: 'Cancel', actionId: 'note:cancel' }),
           buttonElement({ text: 'Cancel', actionId: 'note:cancel' }),
-          buttonElement({ text: 'Create page', actionId: 'note:createPage', style: 'primary' }),
+          buttonElement({
+            text: 'Create page',
+            actionId: 'note:createPage',
+            style: 'primary',
+          }),
         ),
         ),
-
       ],
       ],
     });
     });
   };
   };
 
 
-  handler.cancel = async function(client, interactionPayload, interactionPayloadAccessor, respondUtil) {
+  handler.cancel = async (
+    client,
+    interactionPayload,
+    interactionPayloadAccessor,
+    respondUtil,
+  ) => {
     await respondUtil.deleteOriginal();
     await respondUtil.deleteOriginal();
   };
   };
 
 
-  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) {
-    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil);
+  handler.handleInteractions = async function (
+    client,
+    interactionPayload,
+    interactionPayloadAccessor,
+    handlerMethodName,
+    respondUtil,
+  ) {
+    await this[handlerMethodName](
+      client,
+      interactionPayload,
+      interactionPayloadAccessor,
+      respondUtil,
+    );
   };
   };
 
 
-  handler.createPage = async function(client, interactionPayload, interactionPayloadAccessor, respondUtil) {
+  handler.createPage = async (
+    client,
+    interactionPayload,
+    interactionPayloadAccessor,
+    respondUtil,
+  ) => {
     const user = await User.findUserBySlackMemberId(interactionPayload.user.id);
     const user = await User.findUserBySlackMemberId(interactionPayload.user.id);
-    const path = interactionPayloadAccessor.getStateValues()?.path.path_input.value;
-    const contentsBody = interactionPayloadAccessor.getStateValues()?.contents.contents_input.value;
+    const path =
+      interactionPayloadAccessor.getStateValues()?.path.path_input.value;
+    const contentsBody =
+      interactionPayloadAccessor.getStateValues()?.contents.contents_input
+        .value;
     if (path == null || contentsBody == null) {
     if (path == null || contentsBody == null) {
       throw new SlackCommandHandlerError('All parameters are required.');
       throw new SlackCommandHandlerError('All parameters are required.');
     }
     }
-    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil, user);
+    await createPageService.createPageInGrowi(
+      interactionPayloadAccessor,
+      path,
+      contentsBody,
+      respondUtil,
+      user,
+    );
     await respondUtil.deleteOriginal();
     await respondUtil.deleteOriginal();
   };
   };
 
 

+ 143 - 89
apps/app/src/server/service/slack-command-handler/search.js

@@ -1,5 +1,6 @@
 import {
 import {
-  markdownSectionBlock, divider,
+  divider,
+  markdownSectionBlock,
 } from '@growi/slack/dist/utils/block-kit-builder';
 } from '@growi/slack/dist/utils/block-kit-builder';
 import { generateLastUpdateMrkdwn } from '@growi/slack/dist/utils/generate-last-update-markdown';
 import { generateLastUpdateMrkdwn } from '@growi/slack/dist/utils/generate-last-update-markdown';
 
 
@@ -7,32 +8,32 @@ import loggerFactory from '~/utils/logger';
 
 
 import { growiInfoService } from '../growi-info';
 import { growiInfoService } from '../growi-info';
 
 
-
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 
 
 const PAGINGLIMIT = 7;
 const PAGINGLIMIT = 7;
 
 
-
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler(crowi);
   const handler = new BaseSlackCommandHandler(crowi);
 
 
-
   function getKeywords(growiCommandArgs) {
   function getKeywords(growiCommandArgs) {
     const keywords = growiCommandArgs.join(' ');
     const keywords = growiCommandArgs.join(' ');
     return keywords;
     return keywords;
   }
   }
 
 
   function appendSpeechBaloon(mrkdwn, commentCount) {
   function appendSpeechBaloon(mrkdwn, commentCount) {
-    return (commentCount != null && commentCount > 0)
+    return commentCount != null && commentCount > 0
       ? `${mrkdwn}   :speech_balloon: ${commentCount}`
       ? `${mrkdwn}   :speech_balloon: ${commentCount}`
       : mrkdwn;
       : mrkdwn;
   }
   }
 
 
   function generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs) {
   function generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs) {
     const url = new URL('/_search', appUrl);
     const url = new URL('/_search', appUrl);
-    url.searchParams.append('q', growiCommandArgs.map(kwd => encodeURIComponent(kwd)).join('+'));
+    url.searchParams.append(
+      'q',
+      growiCommandArgs.map((kwd) => encodeURIComponent(kwd)).join('+'),
+    );
     return `<${url.href} | Results page>`;
     return `<${url.href} | Results page>`;
   }
   }
 
 
@@ -45,16 +46,27 @@ module.exports = (crowi) => {
 
 
     const { searchService } = crowi;
     const { searchService } = crowi;
     const options = { limit: PAGINGLIMIT, offset };
     const options = { limit: PAGINGLIMIT, offset };
-    const [results] = await searchService.searchKeyword(keywords, null, {}, options);
+    const [results] = await searchService.searchKeyword(
+      keywords,
+      null,
+      {},
+      options,
+    );
     const resultsTotal = results.meta.total;
     const resultsTotal = results.meta.total;
 
 
     const pages = results.data.map((data) => {
     const pages = results.data.map((data) => {
-      const { path, updated_at: updatedAt, comment_count: commentCount } = data._source;
+      const {
+        path,
+        updated_at: updatedAt,
+        comment_count: commentCount,
+      } = data._source;
       return { path, updatedAt, commentCount };
       return { path, updatedAt, commentCount };
     });
     });
 
 
     return {
     return {
-      pages, offset, resultsTotal,
+      pages,
+      offset,
+      resultsTotal,
     };
     };
   }
   }
 
 
@@ -62,9 +74,7 @@ module.exports = (crowi) => {
     const appUrl = growiInfoService.getSiteUrl();
     const appUrl = growiInfoService.getSiteUrl();
     const appTitle = crowi.appService.getAppTitle();
     const appTitle = crowi.appService.getAppTitle();
 
 
-    const {
-      pages, offset, resultsTotal,
-    } = searchResult;
+    const { pages, offset, resultsTotal } = searchResult;
 
 
     const keywords = getKeywords(growiCommandArgs);
     const keywords = getKeywords(growiCommandArgs);
 
 
@@ -83,17 +93,20 @@ module.exports = (crowi) => {
       elements: [
       elements: [
         {
         {
           type: 'mrkdwn',
           type: 'mrkdwn',
-          text: `keyword(s) : *"${keywords}"*`
-          + `  |  Total ${resultsTotal} pages`
-          + `  |  Current: ${offset + 1} - ${offset + pages.length}`
-          + `  |  ${generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs)}`,
+          text:
+            `keyword(s) : *"${keywords}"*` +
+            `  |  Total ${resultsTotal} pages` +
+            `  |  Current: ${offset + 1} - ${offset + pages.length}` +
+            `  |  ${generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs)}`,
         },
         },
       ],
       ],
     };
     };
 
 
     const now = new Date();
     const now = new Date();
     const blocks = [
     const blocks = [
-      markdownSectionBlock(`:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`),
+      markdownSectionBlock(
+        `:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`,
+      ),
       contextBlock,
       contextBlock,
       { type: 'divider' },
       { type: 'divider' },
       // create an array by map and extract
       // create an array by map and extract
@@ -107,8 +120,9 @@ module.exports = (crowi) => {
           type: 'section',
           type: 'section',
           text: {
           text: {
             type: 'mrkdwn',
             type: 'mrkdwn',
-            text: `${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
-              + `  \`${generateLastUpdateMrkdwn(updatedAt, now)}\``,
+            text:
+              `${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}` +
+              `  \`${generateLastUpdateMrkdwn(updatedAt, now)}\``,
           },
           },
           accessory: {
           accessory: {
             type: 'button',
             type: 'button',
@@ -130,45 +144,39 @@ module.exports = (crowi) => {
       elements: [],
       elements: [],
     };
     };
     // add "Dismiss" button
     // add "Dismiss" button
-    actionBlocks.elements.push(
-      {
-        type: 'button',
-        text: {
-          type: 'plain_text',
-          text: 'Dismiss',
-        },
-        style: 'danger',
-        action_id: 'search:dismissSearchResults',
+    actionBlocks.elements.push({
+      type: 'button',
+      text: {
+        type: 'plain_text',
+        text: 'Dismiss',
       },
       },
-    );
+      style: 'danger',
+      action_id: 'search:dismissSearchResults',
+    });
     // show "Prev" button if previous page exists
     // show "Prev" button if previous page exists
     // eslint-disable-next-line yoda
     // eslint-disable-next-line yoda
     if (0 < offset) {
     if (0 < offset) {
-      actionBlocks.elements.push(
-        {
-          type: 'button',
-          text: {
-            type: 'plain_text',
-            text: '< Prev',
-          },
-          action_id: 'search:showPrevResults',
-          value: JSON.stringify({ offset, growiCommandArgs }),
+      actionBlocks.elements.push({
+        type: 'button',
+        text: {
+          type: 'plain_text',
+          text: '< Prev',
         },
         },
-      );
+        action_id: 'search:showPrevResults',
+        value: JSON.stringify({ offset, growiCommandArgs }),
+      });
     }
     }
     // show "Next" button if next page exists
     // show "Next" button if next page exists
     if (offset + PAGINGLIMIT < resultsTotal) {
     if (offset + PAGINGLIMIT < resultsTotal) {
-      actionBlocks.elements.push(
-        {
-          type: 'button',
-          text: {
-            type: 'plain_text',
-            text: 'Next >',
-          },
-          action_id: 'search:showNextResults',
-          value: JSON.stringify({ offset, growiCommandArgs }),
+      actionBlocks.elements.push({
+        type: 'button',
+        text: {
+          type: 'plain_text',
+          text: 'Next >',
         },
         },
-      );
+        action_id: 'search:showNextResults',
+        value: JSON.stringify({ offset, growiCommandArgs }),
+      });
     }
     }
     blocks.push(actionBlocks);
     blocks.push(actionBlocks);
 
 
@@ -178,7 +186,6 @@ module.exports = (crowi) => {
     };
     };
   }
   }
 
 
-
   async function buildRespondBody(growiCommandArgs) {
   async function buildRespondBody(growiCommandArgs) {
     const firstKeyword = growiCommandArgs[0];
     const firstKeyword = growiCommandArgs[0];
 
 
@@ -187,7 +194,9 @@ module.exports = (crowi) => {
       return {
       return {
         text: 'Input keywords',
         text: 'Input keywords',
         blocks: [
         blocks: [
-          markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
+          markdownSectionBlock(
+            '*Input keywords.*\n Hint\n `/growi search [keyword]`',
+          ),
         ],
         ],
       };
       };
     }
     }
@@ -202,18 +211,30 @@ module.exports = (crowi) => {
       return {
       return {
         text: `No page found with "${keywords}"`,
         text: `No page found with "${keywords}"`,
         blocks: [
         blocks: [
-          markdownSectionBlock(`*No page matches your keyword(s) "${keywords}".*`),
+          markdownSectionBlock(
+            `*No page matches your keyword(s) "${keywords}".*`,
+          ),
           markdownSectionBlock(':mag: *Help: Searching*'),
           markdownSectionBlock(':mag: *Help: Searching*'),
           divider(),
           divider(),
-          markdownSectionBlock('`word1` `word2` (divide with space) \n Search pages that include both word1, word2 in the title or body'),
+          markdownSectionBlock(
+            '`word1` `word2` (divide with space) \n Search pages that include both word1, word2 in the title or body',
+          ),
           divider(),
           divider(),
-          markdownSectionBlock('`"This is GROWI"` (surround with double quotes) \n Search pages that include the phrase "This is GROWI"'),
+          markdownSectionBlock(
+            '`"This is GROWI"` (surround with double quotes) \n Search pages that include the phrase "This is GROWI"',
+          ),
           divider(),
           divider(),
-          markdownSectionBlock('`-keyword` \n Exclude pages that include keyword in the title or body'),
+          markdownSectionBlock(
+            '`-keyword` \n Exclude pages that include keyword in the title or body',
+          ),
           divider(),
           divider(),
-          markdownSectionBlock('`prefix:/user/` \n Search only the pages that the title start with /user/'),
+          markdownSectionBlock(
+            '`prefix:/user/` \n Search only the pages that the title start with /user/',
+          ),
           divider(),
           divider(),
-          markdownSectionBlock('`-prefix:/user/` \n Exclude the pages that the title start with /user/'),
+          markdownSectionBlock(
+            '`-prefix:/user/` \n Exclude the pages that the title start with /user/',
+          ),
           divider(),
           divider(),
           markdownSectionBlock('`tag:wiki` \n Search for pages with wiki tag'),
           markdownSectionBlock('`tag:wiki` \n Search for pages with wiki tag'),
           divider(),
           divider(),
@@ -225,19 +246,34 @@ module.exports = (crowi) => {
     return buildRespondBodyForSearchResult(searchResult, growiCommandArgs);
     return buildRespondBodyForSearchResult(searchResult, growiCommandArgs);
   }
   }
 
 
-
-  handler.handleCommand = async function(growiCommand, client, body, respondUtil) {
+  handler.handleCommand = async (growiCommand, client, body, respondUtil) => {
     const { growiCommandArgs } = growiCommand;
     const { growiCommandArgs } = growiCommand;
 
 
     const respondBody = await buildRespondBody(growiCommandArgs);
     const respondBody = await buildRespondBody(growiCommandArgs);
     await respondUtil.respond(respondBody);
     await respondUtil.respond(respondBody);
   };
   };
 
 
-  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) {
-    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil);
+  handler.handleInteractions = async function (
+    client,
+    interactionPayload,
+    interactionPayloadAccessor,
+    handlerMethodName,
+    respondUtil,
+  ) {
+    await this[handlerMethodName](
+      client,
+      interactionPayload,
+      interactionPayloadAccessor,
+      respondUtil,
+    );
   };
   };
 
 
-  handler.shareSinglePageResult = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+  handler.shareSinglePageResult = async (
+    client,
+    payload,
+    interactionPayloadAccessor,
+    respondUtil,
+  ) => {
     const { user } = payload;
     const { user } = payload;
 
 
     const appUrl = growiInfoService.getSiteUrl();
     const appUrl = growiInfoService.getSiteUrl();
@@ -247,14 +283,13 @@ module.exports = (crowi) => {
     if (value == null) {
     if (value == null) {
       await respondUtil.respond({
       await respondUtil.respond({
         text: 'Error occurred',
         text: 'Error occurred',
-        blocks: [
-          markdownSectionBlock('Failed to share the result.'),
-        ],
+        blocks: [markdownSectionBlock('Failed to share the result.')],
       });
       });
       return;
       return;
     }
     }
 
 
-    const parsedValue = interactionPayloadAccessor.getOriginalData() || JSON.parse(value);
+    const parsedValue =
+      interactionPayloadAccessor.getOriginalData() || JSON.parse(value);
 
 
     // restore page data from value
     // restore page data from value
     const { page, href, pathname } = parsedValue;
     const { page, href, pathname } = parsedValue;
@@ -265,15 +300,18 @@ module.exports = (crowi) => {
     return respondUtil.respondInChannel({
     return respondUtil.respondInChannel({
       blocks: [
       blocks: [
         { type: 'divider' },
         { type: 'divider' },
-        markdownSectionBlock(`${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
+        markdownSectionBlock(
+          `${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`,
+        ),
         {
         {
           type: 'context',
           type: 'context',
           elements: [
           elements: [
             {
             {
               type: 'mrkdwn',
               type: 'mrkdwn',
-              text: `<${decodeURI(appUrl)}|*${appTitle}*>`
-                + `  |  Last updated: \`${generateLastUpdateMrkdwn(updatedAt, now)}\``
-                + `  |  Shared by *${user.username}*`,
+              text:
+                `<${decodeURI(appUrl)}|*${appTitle}*>` +
+                `  |  Last updated: \`${generateLastUpdateMrkdwn(updatedAt, now)}\`` +
+                `  |  Shared by *${user.username}*`,
             },
             },
           ],
           ],
         },
         },
@@ -281,42 +319,58 @@ module.exports = (crowi) => {
     });
     });
   };
   };
 
 
-  async function showPrevOrNextResults(interactionPayloadAccessor, isNext = true, respondUtil) {
-
+  async function showPrevOrNextResults(
+    interactionPayloadAccessor,
+    isNext = true,
+    respondUtil,
+  ) {
     const value = interactionPayloadAccessor.firstAction()?.value;
     const value = interactionPayloadAccessor.firstAction()?.value;
     if (value == null) {
     if (value == null) {
       await respondUtil.respond({
       await respondUtil.respond({
         text: 'Error occurred',
         text: 'Error occurred',
-        blocks: [
-          markdownSectionBlock('Failed to show the next results.'),
-        ],
+        blocks: [markdownSectionBlock('Failed to show the next results.')],
       });
       });
       return;
       return;
     }
     }
 
 
-    const parsedValue = interactionPayloadAccessor.getOriginalData() || JSON.parse(value);
+    const parsedValue =
+      interactionPayloadAccessor.getOriginalData() || JSON.parse(value);
 
 
     const { growiCommandArgs, offset: offsetNum } = parsedValue;
     const { growiCommandArgs, offset: offsetNum } = parsedValue;
     const newOffsetNum = isNext
     const newOffsetNum = isNext
       ? offsetNum + PAGINGLIMIT
       ? offsetNum + PAGINGLIMIT
       : offsetNum - PAGINGLIMIT;
       : offsetNum - PAGINGLIMIT;
 
 
-    const searchResult = await retrieveSearchResults(growiCommandArgs, newOffsetNum);
+    const searchResult = await retrieveSearchResults(
+      growiCommandArgs,
+      newOffsetNum,
+    );
 
 
-    await respondUtil.replaceOriginal(buildRespondBodyForSearchResult(searchResult, growiCommandArgs));
+    await respondUtil.replaceOriginal(
+      buildRespondBodyForSearchResult(searchResult, growiCommandArgs),
+    );
   }
   }
 
 
-  handler.showPrevResults = async function(client, payload, interactionPayloadAccessor, respondUtil) {
-    return showPrevOrNextResults(interactionPayloadAccessor, false, respondUtil);
-  };
-
-  handler.showNextResults = async function(client, payload, interactionPayloadAccessor, respondUtil) {
-    return showPrevOrNextResults(interactionPayloadAccessor, true, respondUtil);
-  };
-
-  handler.dismissSearchResults = async function(client, payload, interactionPayloadAccessor, respondUtil) {
-    return respondUtil.deleteOriginal();
-  };
+  handler.showPrevResults = async (
+    client,
+    payload,
+    interactionPayloadAccessor,
+    respondUtil,
+  ) => showPrevOrNextResults(interactionPayloadAccessor, false, respondUtil);
+
+  handler.showNextResults = async (
+    client,
+    payload,
+    interactionPayloadAccessor,
+    respondUtil,
+  ) => showPrevOrNextResults(interactionPayloadAccessor, true, respondUtil);
+
+  handler.dismissSearchResults = async (
+    client,
+    payload,
+    interactionPayloadAccessor,
+    respondUtil,
+  ) => respondUtil.deleteOriginal();
 
 
   return handler;
   return handler;
 };
 };

+ 11 - 4
apps/app/src/server/service/slack-command-handler/slack-command-handler.js

@@ -1,6 +1,5 @@
 // Any slack command handler should inherit BaseSlackCommandHandler
 // Any slack command handler should inherit BaseSlackCommandHandler
 class BaseSlackCommandHandler {
 class BaseSlackCommandHandler {
-
   /** @type {import('~/server/crowi').default} Crowi instance */
   /** @type {import('~/server/crowi').default} Crowi instance */
   crowi;
   crowi;
 
 
@@ -12,13 +11,21 @@ class BaseSlackCommandHandler {
   /**
   /**
    * Handle /commands endpoint
    * Handle /commands endpoint
    */
    */
-  handleCommand(growiCommand, client, body) { throw new Error('Implement this') }
+  handleCommand(growiCommand, client, body) {
+    throw new Error('Implement this');
+  }
 
 
   /**
   /**
    * Handle interactions
    * Handle interactions
    */
    */
-  handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) { throw new Error('Implement this') }
-
+  handleInteractions(
+    client,
+    interactionPayload,
+    interactionPayloadAccessor,
+    handlerMethodName,
+  ) {
+    throw new Error('Implement this');
+  }
 }
 }
 
 
 module.exports = BaseSlackCommandHandler;
 module.exports = BaseSlackCommandHandler;

+ 135 - 54
apps/app/src/server/service/slack-command-handler/togetter.js

@@ -1,7 +1,11 @@
 import {
 import {
-  inputBlock, actionsBlock, buttonElement, markdownSectionBlock, divider,
+  actionsBlock,
+  buttonElement,
+  divider,
+  inputBlock,
+  markdownSectionBlock,
 } from '@growi/slack/dist/utils/block-kit-builder';
 } from '@growi/slack/dist/utils/block-kit-builder';
-import { respond, deleteOriginal } from '@growi/slack/dist/utils/response-url';
+import { deleteOriginal, respond } from '@growi/slack/dist/utils/response-url';
 import { format, formatDate } from 'date-fns/format';
 import { format, formatDate } from 'date-fns/format';
 import { parse } from 'date-fns/parse';
 import { parse } from 'date-fns/parse';
 
 
@@ -18,7 +22,7 @@ module.exports = (crowi) => {
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
   const handler = new BaseSlackCommandHandler();
 
 
-  handler.handleCommand = async function(growiCommand, client, body) {
+  handler.handleCommand = async function (growiCommand, client, body) {
     await respond(growiCommand.responseUrl, {
     await respond(growiCommand.responseUrl, {
       text: 'Select messages to use.',
       text: 'Select messages to use.',
       blocks: this.togetterMessageBlocks(),
       blocks: this.togetterMessageBlocks(),
@@ -26,23 +30,40 @@ module.exports = (crowi) => {
     return;
     return;
   };
   };
 
 
-  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) {
-    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor);
+  handler.handleInteractions = async function (
+    client,
+    interactionPayload,
+    interactionPayloadAccessor,
+    handlerMethodName,
+  ) {
+    await this[handlerMethodName](
+      client,
+      interactionPayload,
+      interactionPayloadAccessor,
+    );
   };
   };
 
 
-  handler.cancel = async function(client, payload, interactionPayloadAccessor) {
+  handler.cancel = async (client, payload, interactionPayloadAccessor) => {
     await deleteOriginal(interactionPayloadAccessor.getResponseUrl(), {
     await deleteOriginal(interactionPayloadAccessor.getResponseUrl(), {
       delete_original: true,
       delete_original: true,
     });
     });
   };
   };
 
 
-  handler.createPage = async function(client, payload, interactionPayloadAccessor) {
+  handler.createPage = async function (
+    client,
+    payload,
+    interactionPayloadAccessor,
+  ) {
     let result = [];
     let result = [];
     const channelId = payload.channel.id; // this must exist since the type is always block_actions
     const channelId = payload.channel.id; // this must exist since the type is always block_actions
     const userChannelId = payload.user.id;
     const userChannelId = payload.user.id;
 
 
     // validate form
     // validate form
-    const { path, oldest, newest } = await this.togetterValidateForm(client, payload, interactionPayloadAccessor);
+    const { path, oldest, newest } = await this.togetterValidateForm(
+      client,
+      payload,
+      interactionPayloadAccessor,
+    );
     // get messages
     // get messages
     result = await this.togetterGetMessages(client, channelId, newest, oldest);
     result = await this.togetterGetMessages(client, channelId, newest, oldest);
     // clean messages
     // clean messages
@@ -50,37 +71,65 @@ module.exports = (crowi) => {
 
 
     const contentsBody = cleanedContents.join('');
     const contentsBody = cleanedContents.join('');
     // create and send url message
     // create and send url message
-    await this.togetterCreatePageAndSendPreview(client, interactionPayloadAccessor, path, userChannelId, contentsBody);
+    await this.togetterCreatePageAndSendPreview(
+      client,
+      interactionPayloadAccessor,
+      path,
+      userChannelId,
+      contentsBody,
+    );
   };
   };
 
 
-  handler.togetterValidateForm = async function(client, payload, interactionPayloadAccessor) {
+  handler.togetterValidateForm = async (
+    client,
+    payload,
+    interactionPayloadAccessor,
+  ) => {
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
-    const path = interactionPayloadAccessor.getStateValues()?.page_path.page_path.value;
-    let oldest = interactionPayloadAccessor.getStateValues()?.oldest.oldest.value;
-    let newest = interactionPayloadAccessor.getStateValues()?.newest.newest.value;
+    const path =
+      interactionPayloadAccessor.getStateValues()?.page_path.page_path.value;
+    let oldest =
+      interactionPayloadAccessor.getStateValues()?.oldest.oldest.value;
+    let newest =
+      interactionPayloadAccessor.getStateValues()?.newest.newest.value;
 
 
     if (oldest == null || newest == null || path == null) {
     if (oldest == null || newest == null || path == null) {
-      throw new SlackCommandHandlerError('All parameters are required. (Oldest datetime, Newst datetime and Page path)');
+      throw new SlackCommandHandlerError(
+        'All parameters are required. (Oldest datetime, Newst datetime and Page path)',
+      );
     }
     }
 
 
     /**
     /**
      * RegExp for datetime yyyy/MM/dd-HH:mm
      * RegExp for datetime yyyy/MM/dd-HH:mm
      * @see https://regex101.com/r/XbxdNo/1
      * @see https://regex101.com/r/XbxdNo/1
      */
      */
-    const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/);
+    const regexpDatetime = new RegExp(
+      /^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/,
+    );
 
 
     if (!regexpDatetime.test(oldest.trim())) {
     if (!regexpDatetime.test(oldest.trim())) {
-      throw new SlackCommandHandlerError('Datetime format for oldest must be yyyy/MM/dd-HH:mm');
+      throw new SlackCommandHandlerError(
+        'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
+      );
     }
     }
     if (!regexpDatetime.test(newest.trim())) {
     if (!regexpDatetime.test(newest.trim())) {
-      throw new SlackCommandHandlerError('Datetime format for newest must be yyyy/MM/dd-HH:mm');
+      throw new SlackCommandHandlerError(
+        'Datetime format for newest must be yyyy/MM/dd-HH:mm',
+      );
     }
     }
-    oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
+    oldest =
+      parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 +
+      grwTzoffset;
     // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
     // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
-    newest = parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
+    newest =
+      parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 +
+      grwTzoffset +
+      60;
 
 
     if (oldest > newest) {
     if (oldest > newest) {
-      throw new SlackCommandHandlerError('Oldest datetime must be older than the newest date time.');
+      throw new SlackCommandHandlerError(
+        'Oldest datetime must be older than the newest date time.',
+      );
     }
     }
 
 
     return { path, oldest, newest };
     return { path, oldest, newest };
@@ -96,14 +145,13 @@ module.exports = (crowi) => {
     });
     });
   }
   }
 
 
-  handler.togetterGetMessages = async function(client, channelId, newest, oldest) {
+  handler.togetterGetMessages = async (client, channelId, newest, oldest) => {
     let result;
     let result;
 
 
     // first attempt
     // first attempt
     try {
     try {
       result = await retrieveHistory(client, channelId, newest, oldest);
       result = await retrieveHistory(client, channelId, newest, oldest);
-    }
-    catch (err) {
+    } catch (err) {
       const errorCode = err.data?.errorCode;
       const errorCode = err.data?.errorCode;
 
 
       if (errorCode === 'not_in_channel') {
       if (errorCode === 'not_in_channel') {
@@ -112,12 +160,11 @@ module.exports = (crowi) => {
           channel: channelId,
           channel: channelId,
         });
         });
         result = await retrieveHistory(client, channelId, newest, oldest);
         result = await retrieveHistory(client, channelId, newest, oldest);
-      }
-      else if (errorCode === 'channel_not_found') {
-
-        const message = ':cry: GROWI Bot couldn\'t get history data because *this channel was private*.'
-          + '\nPlease add GROWI bot to this channel.'
-          + '\n';
+      } else if (errorCode === 'channel_not_found') {
+        const message =
+          ":cry: GROWI Bot couldn't get history data because *this channel was private*." +
+          '\nPlease add GROWI bot to this channel.' +
+          '\n';
         throw new SlackCommandHandlerError(message, {
         throw new SlackCommandHandlerError(message, {
           respondBody: {
           respondBody: {
             text: message,
             text: message,
@@ -125,26 +172,28 @@ module.exports = (crowi) => {
               markdownSectionBlock(message),
               markdownSectionBlock(message),
               {
               {
                 type: 'image',
                 type: 'image',
-                image_url: 'https://user-images.githubusercontent.com/1638767/135834548-2f6b8ce6-30a7-4d47-9fdc-a58ddd692b7e.png',
+                image_url:
+                  'https://user-images.githubusercontent.com/1638767/135834548-2f6b8ce6-30a7-4d47-9fdc-a58ddd692b7e.png',
                 alt_text: 'Add app to this channel',
                 alt_text: 'Add app to this channel',
               },
               },
             ],
             ],
           },
           },
         });
         });
-      }
-      else {
+      } else {
         throw err;
         throw err;
       }
       }
     }
     }
 
 
     // return if no message found
     // return if no message found
     if (result.messages.length === 0) {
     if (result.messages.length === 0) {
-      throw new SlackCommandHandlerError('No message found from togetter command. Try different datetime.');
+      throw new SlackCommandHandlerError(
+        'No message found from togetter command. Try different datetime.',
+      );
     }
     }
     return result;
     return result;
   };
   };
 
 
-  handler.togetterCleanMessages = async function(messages) {
+  handler.togetterCleanMessages = async (messages) => {
     const cleanedContents = [];
     const cleanedContents = [];
     let lastMessage = {};
     let lastMessage = {};
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
@@ -171,8 +220,18 @@ module.exports = (crowi) => {
     return cleanedContents;
     return cleanedContents;
   };
   };
 
 
-  handler.togetterCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, userChannelId, contentsBody) {
-    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody);
+  handler.togetterCreatePageAndSendPreview = async (
+    client,
+    interactionPayloadAccessor,
+    path,
+    userChannelId,
+    contentsBody,
+  ) => {
+    await createPageService.createPageInGrowi(
+      interactionPayloadAccessor,
+      path,
+      contentsBody,
+    );
 
 
     // send preview to dm
     // send preview to dm
     await client.chat.postMessage({
     await client.chat.postMessage({
@@ -191,15 +250,33 @@ module.exports = (crowi) => {
     });
     });
   };
   };
 
 
-  handler.togetterMessageBlocks = function() {
+  handler.togetterMessageBlocks = function () {
     return [
     return [
-      markdownSectionBlock('Select the oldest and newest datetime of the messages to use.'),
-      inputBlock(this.plainTextInputElementWithInitialTime('oldest'), 'oldest', 'Oldest datetime'),
-      inputBlock(this.plainTextInputElementWithInitialTime('newest'), 'newest', 'Newest datetime'),
-      inputBlock(this.togetterInputBlockElement('page_path', '/'), 'page_path', 'Page path'),
+      markdownSectionBlock(
+        'Select the oldest and newest datetime of the messages to use.',
+      ),
+      inputBlock(
+        this.plainTextInputElementWithInitialTime('oldest'),
+        'oldest',
+        'Oldest datetime',
+      ),
+      inputBlock(
+        this.plainTextInputElementWithInitialTime('newest'),
+        'newest',
+        'Newest datetime',
+      ),
+      inputBlock(
+        this.togetterInputBlockElement('page_path', '/'),
+        'page_path',
+        'Page path',
+      ),
       actionsBlock(
       actionsBlock(
         buttonElement({ text: 'Cancel', actionId: 'togetter:cancel' }),
         buttonElement({ text: 'Cancel', actionId: 'togetter:cancel' }),
-        buttonElement({ text: 'Create page', actionId: 'togetter:createPage', style: 'primary' }),
+        buttonElement({
+          text: 'Create page',
+          actionId: 'togetter:createPage',
+          style: 'primary',
+        }),
       ),
       ),
     ];
     ];
   };
   };
@@ -208,21 +285,25 @@ module.exports = (crowi) => {
    * Plain-text input element
    * Plain-text input element
    * https://api.slack.com/reference/block-kit/block-elements#input
    * https://api.slack.com/reference/block-kit/block-elements#input
    */
    */
-  handler.togetterInputBlockElement = function(actionId, placeholderText = 'Write something ...') {
-    return {
-      type: 'plain_text_input',
-      placeholder: {
-        type: 'plain_text',
-        text: placeholderText,
-      },
-      action_id: actionId,
-    };
-  };
+  handler.togetterInputBlockElement = (
+    actionId,
+    placeholderText = 'Write something ...',
+  ) => ({
+    type: 'plain_text_input',
+    placeholder: {
+      type: 'plain_text',
+      text: placeholderText,
+    },
+    action_id: actionId,
+  });
 
 
-  handler.plainTextInputElementWithInitialTime = function(actionId) {
+  handler.plainTextInputElementWithInitialTime = (actionId) => {
     const tzDateSec = new Date().getTime();
     const tzDateSec = new Date().getTime();
     const grwTzoffset = crowi.appService.getTzoffset() * 60 * 1000;
     const grwTzoffset = crowi.appService.getTzoffset() * 60 * 1000;
-    const initialDateTime = format(new Date(tzDateSec - grwTzoffset), 'yyyy/MM/dd-HH:mm');
+    const initialDateTime = format(
+      new Date(tzDateSec - grwTzoffset),
+      'yyyy/MM/dd-HH:mm',
+    );
     return {
     return {
       type: 'plain_text_input',
       type: 'plain_text_input',
       action_id: actionId,
       action_id: actionId,

+ 10 - 4
apps/app/src/server/service/slack-event-handler/base-event-handler.ts

@@ -4,9 +4,15 @@ import type { WebClient } from '@slack/web-api';
 import type { EventActionsPermission } from '../../interfaces/slack-integration/events';
 import type { EventActionsPermission } from '../../interfaces/slack-integration/events';
 
 
 export interface SlackEventHandler<T> {
 export interface SlackEventHandler<T> {
+  shouldHandle(
+    eventType: string,
+    permission: EventActionsPermission,
+    channel?: string,
+  ): boolean;
 
 
-  shouldHandle(eventType: string, permission: EventActionsPermission, channel?: string): boolean
-
-  handleEvent(client: WebClient, growiBotEvent: GrowiBotEvent<T>, data?: any): Promise<void>
-
+  handleEvent(
+    client: WebClient,
+    growiBotEvent: GrowiBotEvent<T>,
+    data?: any,
+  ): Promise<void>;
 }
 }

+ 105 - 63
apps/app/src/server/service/slack-event-handler/link-shared.ts

@@ -1,9 +1,7 @@
-import { PageGrant, type IPage } from '@growi/core';
+import { type IPage, PageGrant } from '@growi/core';
 import type { GrowiBotEvent } from '@growi/slack';
 import type { GrowiBotEvent } from '@growi/slack';
 import { generateLastUpdateMrkdwn } from '@growi/slack/dist/utils/generate-last-update-markdown';
 import { generateLastUpdateMrkdwn } from '@growi/slack/dist/utils/generate-last-update-markdown';
-import type {
-  MessageAttachment, LinkUnfurls, WebClient,
-} from '@slack/web-api';
+import type { LinkUnfurls, MessageAttachment, WebClient } from '@slack/web-api';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
@@ -13,23 +11,30 @@ import type { PageModel } from '~/server/models/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type {
 import type {
-  DataForUnfurl, PublicData, UnfurlEventLink, UnfurlRequestEvent,
+  DataForUnfurl,
+  PublicData,
+  UnfurlEventLink,
+  UnfurlRequestEvent,
 } from '../../interfaces/slack-integration/link-shared-unfurl';
 } from '../../interfaces/slack-integration/link-shared-unfurl';
 import { growiInfoService } from '../growi-info';
 import { growiInfoService } from '../growi-info';
-
 import type { SlackEventHandler } from './base-event-handler';
 import type { SlackEventHandler } from './base-event-handler';
 
 
 const logger = loggerFactory('growi:service:SlackEventHandler:link-shared');
 const logger = loggerFactory('growi:service:SlackEventHandler:link-shared');
 
 
-export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEvent> {
-
+export class LinkSharedEventHandler
+  implements SlackEventHandler<UnfurlRequestEvent>
+{
   crowi: Crowi;
   crowi: Crowi;
 
 
   constructor(crowi: Crowi) {
   constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
   }
   }
 
 
-  shouldHandle(eventType: string, permission: EventActionsPermission, channel: string): boolean {
+  shouldHandle(
+    eventType: string,
+    permission: EventActionsPermission,
+    channel: string,
+  ): boolean {
     if (eventType !== 'link_shared') return false;
     if (eventType !== 'link_shared') return false;
 
 
     const unfurlPermission = permission.get('unfurl');
     const unfurlPermission = permission.get('unfurl');
@@ -41,7 +46,11 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
     return unfurlPermission.includes(channel);
     return unfurlPermission.includes(channel);
   }
   }
 
 
-  async handleEvent(client: WebClient, growiBotEvent: GrowiBotEvent<UnfurlRequestEvent>, data?: {origin: string}): Promise<void> {
+  async handleEvent(
+    client: WebClient,
+    growiBotEvent: GrowiBotEvent<UnfurlRequestEvent>,
+    data?: { origin: string },
+  ): Promise<void> {
     const { event } = growiBotEvent;
     const { event } = growiBotEvent;
     const origin = data?.origin || growiInfoService.getSiteUrl();
     const origin = data?.origin || growiInfoService.getSiteUrl();
     const { channel, message_ts: ts, links } = event;
     const { channel, message_ts: ts, links } = event;
@@ -49,49 +58,56 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
     let unfurlData: DataForUnfurl[];
     let unfurlData: DataForUnfurl[];
     try {
     try {
       unfurlData = await this.generateUnfurlsObject(links);
       unfurlData = await this.generateUnfurlsObject(links);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Failed to generate unfurl data:', err);
       logger.error('Failed to generate unfurl data:', err);
       throw err;
       throw err;
     }
     }
 
 
     // unfurl
     // unfurl
-    const unfurlResults = await Promise.allSettled(unfurlData.map(async(data: DataForUnfurl) => {
-      const toUrl = urljoin(origin, data.id);
-
-      let targetUrl;
-      if (data.isPermalink) {
-        targetUrl = urljoin(origin, data.id);
-      }
-      else {
-        targetUrl = urljoin(origin, data.path);
-      }
-
-      let unfurls: LinkUnfurls;
-
-      if (data.isPublic === false) {
-        unfurls = {
-          [targetUrl]: {
-            text: 'Page is not public.',
-          },
-        };
-      }
-      else {
-        unfurls = this.generateLinkUnfurls(data as PublicData, targetUrl, toUrl);
-      }
-
-      await client.chat.unfurl({
-        channel,
-        ts,
-        unfurls,
-      });
-    }));
+    const unfurlResults = await Promise.allSettled(
+      unfurlData.map(async (data: DataForUnfurl) => {
+        const toUrl = urljoin(origin, data.id);
+
+        let targetUrl: string;
+        if (data.isPermalink) {
+          targetUrl = urljoin(origin, data.id);
+        } else {
+          targetUrl = urljoin(origin, data.path);
+        }
+
+        let unfurls: LinkUnfurls;
+
+        if (data.isPublic === false) {
+          unfurls = {
+            [targetUrl]: {
+              text: 'Page is not public.',
+            },
+          };
+        } else {
+          unfurls = this.generateLinkUnfurls(
+            data as PublicData,
+            targetUrl,
+            toUrl,
+          );
+        }
+
+        await client.chat.unfurl({
+          channel,
+          ts,
+          unfurls,
+        });
+      }),
+    );
 
 
     this.logErrorRejectedResults(unfurlResults);
     this.logErrorRejectedResults(unfurlResults);
   }
   }
 
 
   // builder method for unfurl parameter
   // builder method for unfurl parameter
-  generateLinkUnfurls(body: PublicData, growiTargetUrl: string, toUrl: string): LinkUnfurls {
+  generateLinkUnfurls(
+    body: PublicData,
+    growiTargetUrl: string,
+    toUrl: string,
+  ): LinkUnfurls {
     const { pageBody: text, updatedAt } = body;
     const { pageBody: text, updatedAt } = body;
 
 
     const appTitle = this.crowi.appService.getAppTitle();
     const appTitle = this.crowi.appService.getAppTitle();
@@ -101,8 +117,9 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
       title: body.path,
       title: body.path,
       title_link: toUrl, // permalink
       title_link: toUrl, // permalink
       text,
       text,
-      footer: `<${decodeURI(siteUrl)}|*${appTitle}*>`
-      + `  |  Last updated: \`${generateLastUpdateMrkdwn(updatedAt, new Date())}\``,
+      footer:
+        `<${decodeURI(siteUrl)}|*${appTitle}*>` +
+        `  |  Last updated: \`${generateLastUpdateMrkdwn(updatedAt, new Date())}\``,
     };
     };
 
 
     const unfurls: LinkUnfurls = {
     const unfurls: LinkUnfurls = {
@@ -111,7 +128,9 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
     return unfurls;
     return unfurls;
   }
   }
 
 
-  async generateUnfurlsObject(links: UnfurlEventLink[]): Promise<DataForUnfurl[]> {
+  async generateUnfurlsObject(
+    links: UnfurlEventLink[],
+  ): Promise<DataForUnfurl[]> {
     // generate paths array
     // generate paths array
     const pathOrIds: string[] = links.map((link) => {
     const pathOrIds: string[] = links.map((link) => {
       const { url: growiTargetUrl } = link;
       const { url: growiTargetUrl } = link;
@@ -121,8 +140,10 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
     });
     });
 
 
     const idRegExp = /^\/[0-9a-z]{24}$/;
     const idRegExp = /^\/[0-9a-z]{24}$/;
-    const paths = pathOrIds.filter(pathOrId => !idRegExp.test(pathOrId));
-    const ids = pathOrIds.filter(pathOrId => idRegExp.test(pathOrId)).map(id => id.replace('/', '')); // remove a slash
+    const paths = pathOrIds.filter((pathOrId) => !idRegExp.test(pathOrId));
+    const ids = pathOrIds
+      .filter((pathOrId) => idRegExp.test(pathOrId))
+      .map((id) => id.replace('/', '')); // remove a slash
 
 
     // get pages with revision
     // get pages with revision
     const Page = mongoose.model<IPage, PageModel>('Page');
     const Page = mongoose.model<IPage, PageModel>('Page');
@@ -131,56 +152,77 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
     const pageQueryBuilderByPaths = new PageQueryBuilder(Page.find());
     const pageQueryBuilderByPaths = new PageQueryBuilder(Page.find());
     const pagesByPaths = await pageQueryBuilderByPaths
     const pagesByPaths = await pageQueryBuilderByPaths
       .addConditionToListByPathsArray(paths)
       .addConditionToListByPathsArray(paths)
-      .query
-      .populate('revision')
+      .query.populate('revision')
       .lean()
       .lean()
       .exec();
       .exec();
 
 
     const pageQueryBuilderByIds = new PageQueryBuilder(Page.find());
     const pageQueryBuilderByIds = new PageQueryBuilder(Page.find());
     const pagesByIds = await pageQueryBuilderByIds
     const pagesByIds = await pageQueryBuilderByIds
       .addConditionToListByPageIdsArray(ids)
       .addConditionToListByPageIdsArray(ids)
-      .query
-      .populate('revision')
+      .query.populate('revision')
       .lean()
       .lean()
       .exec();
       .exec();
 
 
-    const unfurlDataFromNormalLinks = this.generateDataForUnfurl(pagesByPaths, false);
-    const unfurlDataFromPermalinks = this.generateDataForUnfurl(pagesByIds, true);
+    const unfurlDataFromNormalLinks = this.generateDataForUnfurl(
+      pagesByPaths,
+      false,
+    );
+    const unfurlDataFromPermalinks = this.generateDataForUnfurl(
+      pagesByIds,
+      true,
+    );
 
 
     return [...unfurlDataFromNormalLinks, ...unfurlDataFromPermalinks];
     return [...unfurlDataFromNormalLinks, ...unfurlDataFromPermalinks];
   }
   }
 
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  private generateDataForUnfurl(pages: any, isPermalink: boolean): DataForUnfurl[] {
+  private generateDataForUnfurl(
+    pages: any,
+    isPermalink: boolean,
+  ): DataForUnfurl[] {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const unfurlData: DataForUnfurl[] = [];
     const unfurlData: DataForUnfurl[] = [];
 
 
-    pages.forEach((page) => {
+    for (const page of pages) {
       // not send non-public page
       // not send non-public page
       if (page.grant !== PageGrant.GRANT_PUBLIC) {
       if (page.grant !== PageGrant.GRANT_PUBLIC) {
-        return unfurlData.push({
-          isPublic: false, isPermalink, id: page._id.toString(), path: page.path,
+        unfurlData.push({
+          isPublic: false,
+          isPermalink,
+          id: page._id.toString(),
+          path: page.path,
         });
         });
+        continue;
       }
       }
 
 
       // public page
       // public page
       const { updatedAt, commentCount } = page;
       const { updatedAt, commentCount } = page;
       const { body } = page.revision;
       const { body } = page.revision;
       unfurlData.push({
       unfurlData.push({
-        isPublic: true, isPermalink, id: page._id.toString(), path: page.path, pageBody: body, updatedAt, commentCount,
+        isPublic: true,
+        isPermalink,
+        id: page._id.toString(),
+        path: page.path,
+        pageBody: body,
+        updatedAt,
+        commentCount,
       });
       });
-    });
+    }
 
 
     return unfurlData;
     return unfurlData;
   }
   }
 
 
   // Promise util method to output rejected results
   // Promise util method to output rejected results
   private logErrorRejectedResults<T>(results: PromiseSettledResult<T>[]): void {
   private logErrorRejectedResults<T>(results: PromiseSettledResult<T>[]): void {
-    const rejectedResults: PromiseRejectedResult[] = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
+    const rejectedResults: PromiseRejectedResult[] = results.filter(
+      (result): result is PromiseRejectedResult => result.status === 'rejected',
+    );
 
 
     rejectedResults.forEach((rejected, i) => {
     rejectedResults.forEach((rejected, i) => {
-      logger.error(`Error occurred (count: ${i}): `, rejected.reason.toString());
+      logger.error(
+        `Error occurred (count: ${i}): `,
+        rejected.reason.toString(),
+      );
     });
     });
   }
   }
-
 }
 }

+ 115 - 53
apps/app/src/server/service/slack-integration.ts

@@ -1,16 +1,16 @@
 import {
 import {
-  SlackbotType, type GrowiCommand, type GrowiBotEvent,
+  type GrowiBotEvent,
+  type GrowiCommand,
+  SlackbotType,
 } from '@growi/slack';
 } from '@growi/slack';
 import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder';
 import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder';
 import type { InteractionPayloadAccessor } from '@growi/slack/dist/utils/interaction-payload-accessor';
 import type { InteractionPayloadAccessor } from '@growi/slack/dist/utils/interaction-payload-accessor';
 import type { RespondUtil } from '@growi/slack/dist/utils/respond-util-factory';
 import type { RespondUtil } from '@growi/slack/dist/utils/respond-util-factory';
 import { generateWebClient } from '@growi/slack/dist/utils/webclient-factory';
 import { generateWebClient } from '@growi/slack/dist/utils/webclient-factory';
-import type { WebClient } from '@slack/web-api';
-import { type ChatPostMessageArguments } from '@slack/web-api';
+import type { ChatPostMessageArguments, WebClient } from '@slack/web-api';
 import type { IncomingWebhookSendArguments } from '@slack/webhook';
 import type { IncomingWebhookSendArguments } from '@slack/webhook';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
-
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../crowi';
 import type Crowi from '../crowi';
@@ -18,7 +18,6 @@ import type { EventActionsPermission } from '../interfaces/slack-integration/eve
 import S2sMessage from '../models/vo/s2s-message';
 import S2sMessage from '../models/vo/s2s-message';
 import { SlackCommandHandlerError } from '../models/vo/slack-command-handler-error';
 import { SlackCommandHandlerError } from '../models/vo/slack-command-handler-error';
 import { slackLegacyUtilFactory } from '../util/slack-legacy';
 import { slackLegacyUtilFactory } from '../util/slack-legacy';
-
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 import type { S2sMessagingService } from './s2s-messaging/base';
 import type { S2sMessagingService } from './s2s-messaging/base';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
@@ -31,7 +30,6 @@ const OFFICIAL_SLACKBOT_PROXY_URI = 'https://slackbot-proxy.growi.org';
 type S2sMessageForSlackIntegration = S2sMessage & { updatedAt: Date };
 type S2sMessageForSlackIntegration = S2sMessage & { updatedAt: Date };
 
 
 export class SlackIntegrationService implements S2sMessageHandlable {
 export class SlackIntegrationService implements S2sMessageHandlable {
-
   crowi: Crowi;
   crowi: Crowi;
 
 
   s2sMessagingService!: S2sMessagingService;
   s2sMessagingService!: S2sMessagingService;
@@ -61,10 +59,12 @@ export class SlackIntegrationService implements S2sMessageHandlable {
       return false;
       return false;
     }
     }
 
 
-    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
+    return (
+      this.lastLoadedAt == null ||
+      this.lastLoadedAt < new Date(s2sMessage.updatedAt)
+    );
   }
   }
 
 
-
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
@@ -80,13 +80,17 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     const { s2sMessagingService } = this;
     const { s2sMessagingService } = this;
 
 
     if (s2sMessagingService != null) {
     if (s2sMessagingService != null) {
-      const s2sMessage = new S2sMessage('slackIntegrationServiceUpdated', { updatedAt: new Date() });
+      const s2sMessage = new S2sMessage('slackIntegrationServiceUpdated', {
+        updatedAt: new Date(),
+      });
 
 
       try {
       try {
         await s2sMessagingService.publish(s2sMessage);
         await s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      } catch (e) {
+        logger.error(
+          'Failed to publish update message with S2sMessagingService: ',
+          e.message,
+        );
       }
       }
     }
     }
   }
   }
@@ -96,14 +100,18 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   }
   }
 
 
   get isSlackbotConfigured(): boolean {
   get isSlackbotConfigured(): boolean {
-    const hasSlackbotType = !!configManager.getConfig('slackbot:currentBotType');
+    const hasSlackbotType = !!configManager.getConfig(
+      'slackbot:currentBotType',
+    );
     return hasSlackbotType;
     return hasSlackbotType;
   }
   }
 
 
   get isSlackLegacyConfigured(): boolean {
   get isSlackLegacyConfigured(): boolean {
     // for legacy util
     // for legacy util
     const hasSlackToken = !!configManager.getConfig('slack:token');
     const hasSlackToken = !!configManager.getConfig('slack:token');
-    const hasSlackIwhUrl = !!configManager.getConfig('slack:incomingWebhookUrl');
+    const hasSlackIwhUrl = !!configManager.getConfig(
+      'slack:incomingWebhookUrl',
+    );
 
 
     return hasSlackToken || hasSlackIwhUrl;
     return hasSlackToken || hasSlackIwhUrl;
   }
   }
@@ -111,7 +119,9 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   private isCheckTypeValid(): boolean {
   private isCheckTypeValid(): boolean {
     const currentBotType = configManager.getConfig('slackbot:currentBotType');
     const currentBotType = configManager.getConfig('slackbot:currentBotType');
     if (currentBotType == null) {
     if (currentBotType == null) {
-      throw new Error('The config \'SLACKBOT_TYPE\'(ns: \'crowi\', key: \'slackbot:currentBotType\') must be set.');
+      throw new Error(
+        "The config 'SLACKBOT_TYPE'(ns: 'crowi', key: 'slackbot:currentBotType') must be set.",
+      );
     }
     }
 
 
     return true;
     return true;
@@ -145,7 +155,9 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     const token = configManager.getConfig('slackbot:withoutProxy:botToken');
     const token = configManager.getConfig('slackbot:withoutProxy:botToken');
 
 
     if (token == null) {
     if (token == null) {
-      throw new Error('The config \'SLACK_BOT_TOKEN\'(ns: \'crowi\', key: \'slackbot:withoutProxy:botToken\') must be set.');
+      throw new Error(
+        "The config 'SLACK_BOT_TOKEN'(ns: 'crowi', key: 'slackbot:withoutProxy:botToken') must be set.",
+      );
     }
     }
 
 
     return generateWebClient(token);
     return generateWebClient(token);
@@ -160,13 +172,19 @@ export class SlackIntegrationService implements S2sMessageHandlable {
 
 
     const SlackAppIntegration = mongoose.model('SlackAppIntegration');
     const SlackAppIntegration = mongoose.model('SlackAppIntegration');
 
 
-    const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+    const slackAppIntegration = await SlackAppIntegration.findOne({
+      tokenPtoG,
+    });
 
 
     if (slackAppIntegration == null) {
     if (slackAppIntegration == null) {
-      throw new Error('No SlackAppIntegration exists that corresponds to the tokenPtoG specified.');
+      throw new Error(
+        'No SlackAppIntegration exists that corresponds to the tokenPtoG specified.',
+      );
     }
     }
 
 
-    return this.generateClientBySlackAppIntegration(slackAppIntegration as unknown as { tokenGtoP: string; });
+    return this.generateClientBySlackAppIntegration(
+      slackAppIntegration as unknown as { tokenGtoP: string },
+    );
   }
   }
 
 
   /**
   /**
@@ -184,20 +202,26 @@ export class SlackIntegrationService implements S2sMessageHandlable {
 
 
     // retrieve primary SlackAppIntegration
     // retrieve primary SlackAppIntegration
     const SlackAppIntegration = mongoose.model('SlackAppIntegration');
     const SlackAppIntegration = mongoose.model('SlackAppIntegration');
-    const slackAppIntegration = await SlackAppIntegration.findOne({ isPrimary: true });
+    const slackAppIntegration = await SlackAppIntegration.findOne({
+      isPrimary: true,
+    });
 
 
     if (slackAppIntegration == null) {
     if (slackAppIntegration == null) {
       throw new Error('None of the primary SlackAppIntegration exists.');
       throw new Error('None of the primary SlackAppIntegration exists.');
     }
     }
 
 
-    return this.generateClientBySlackAppIntegration(slackAppIntegration as unknown as { tokenGtoP: string; });
+    return this.generateClientBySlackAppIntegration(
+      slackAppIntegration as unknown as { tokenGtoP: string },
+    );
   }
   }
 
 
   /**
   /**
    * generate WebClient instance by SlackAppIntegration
    * generate WebClient instance by SlackAppIntegration
    * @param slackAppIntegration
    * @param slackAppIntegration
    */
    */
-  async generateClientBySlackAppIntegration(slackAppIntegration: { tokenGtoP: string }): Promise<WebClient> {
+  async generateClientBySlackAppIntegration(slackAppIntegration: {
+    tokenGtoP: string;
+  }): Promise<WebClient> {
     this.isCheckTypeValid();
     this.isCheckTypeValid();
 
 
     // connect to proxy
     // connect to proxy
@@ -209,33 +233,37 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     return generateWebClient(undefined, serverUri.toString(), headers);
     return generateWebClient(undefined, serverUri.toString(), headers);
   }
   }
 
 
-  async postMessage(messageArgs: ChatPostMessageArguments, slackAppIntegration?: { tokenGtoP: string; }): Promise<void> {
+  async postMessage(
+    messageArgs: ChatPostMessageArguments,
+    slackAppIntegration?: { tokenGtoP: string },
+  ): Promise<void> {
     // use legacy slack configuration
     // use legacy slack configuration
     if (this.isSlackLegacyConfigured && !this.isSlackbotConfigured) {
     if (this.isSlackLegacyConfigured && !this.isSlackbotConfigured) {
       return this.postMessageWithLegacyUtil(messageArgs);
       return this.postMessageWithLegacyUtil(messageArgs);
     }
     }
 
 
-    const client = slackAppIntegration == null
-      ? await this.generateClientForPrimaryWorkspace()
-      : await this.generateClientBySlackAppIntegration(slackAppIntegration);
+    const client =
+      slackAppIntegration == null
+        ? await this.generateClientForPrimaryWorkspace()
+        : await this.generateClientBySlackAppIntegration(slackAppIntegration);
 
 
     try {
     try {
       await client.chat.postMessage(messageArgs);
       await client.chat.postMessage(messageArgs);
-    }
-    catch (error) {
+    } catch (error) {
       logger.debug('Post error', error);
       logger.debug('Post error', error);
       logger.debug('Sent data to slack is:', messageArgs);
       logger.debug('Sent data to slack is:', messageArgs);
       throw error;
       throw error;
     }
     }
   }
   }
 
 
-  private async postMessageWithLegacyUtil(messageArgs: ChatPostMessageArguments | IncomingWebhookSendArguments): Promise<void> {
+  private async postMessageWithLegacyUtil(
+    messageArgs: ChatPostMessageArguments | IncomingWebhookSendArguments,
+  ): Promise<void> {
     const slackLegacyUtil = slackLegacyUtilFactory(configManager);
     const slackLegacyUtil = slackLegacyUtilFactory(configManager);
 
 
     try {
     try {
       await slackLegacyUtil.postMessage(messageArgs);
       await slackLegacyUtil.postMessage(messageArgs);
-    }
-    catch (error) {
+    } catch (error) {
       logger.debug('Post error', error);
       logger.debug('Post error', error);
       logger.debug('Sent data to slack is:', messageArgs);
       logger.debug('Sent data to slack is:', messageArgs);
       throw error;
       throw error;
@@ -245,22 +273,28 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   /**
   /**
    * Handle /commands endpoint
    * Handle /commands endpoint
    */
    */
-  async handleCommandRequest(growiCommand: GrowiCommand, client, body, respondUtil: RespondUtil): Promise<void> {
+  async handleCommandRequest(
+    growiCommand: GrowiCommand,
+    client,
+    body,
+    respondUtil: RespondUtil,
+  ): Promise<void> {
     const { growiCommandType } = growiCommand;
     const { growiCommandType } = growiCommand;
     const modulePath = `./slack-command-handler/${growiCommandType}`;
     const modulePath = `./slack-command-handler/${growiCommandType}`;
 
 
-    let handler;
+    let handler: any;
     try {
     try {
       handler = require(modulePath)(this.crowi);
       handler = require(modulePath)(this.crowi);
-    }
-    catch (err) {
+    } catch (err) {
       const text = `*No command.*\n \`command: ${growiCommand.text}\``;
       const text = `*No command.*\n \`command: ${growiCommand.text}\``;
       logger.error(err);
       logger.error(err);
       throw new SlackCommandHandlerError(text, {
       throw new SlackCommandHandlerError(text, {
         respondBody: {
         respondBody: {
           text,
           text,
           blocks: [
           blocks: [
-            markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
+            markdownSectionBlock(
+              '*No command.*\n Hint\n `/growi [command] [keyword]`',
+            ),
           ],
           ],
         },
         },
       });
       });
@@ -271,48 +305,75 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   }
   }
 
 
   async handleBlockActionsRequest(
   async handleBlockActionsRequest(
-      client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor, respondUtil: RespondUtil,
+    client,
+    interactionPayload: any,
+    interactionPayloadAccessor: InteractionPayloadAccessor,
+    respondUtil: RespondUtil,
   ): Promise<void> {
   ): Promise<void> {
-    const { actionId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
+    const { actionId } =
+      interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const commandName = actionId.split(':')[0];
     const commandName = actionId.split(':')[0];
     const handlerMethodName = actionId.split(':')[1];
     const handlerMethodName = actionId.split(':')[1];
 
 
     const modulePath = `./slack-command-handler/${commandName}`;
     const modulePath = `./slack-command-handler/${commandName}`;
 
 
-    let handler;
+    let handler: any;
     try {
     try {
       handler = require(modulePath)(this.crowi);
       handler = require(modulePath)(this.crowi);
-    }
-    catch (err) {
-      throw new SlackCommandHandlerError(`No interaction.\n \`actionId: ${actionId}\``);
+    } catch (err) {
+      throw new SlackCommandHandlerError(
+        `No interaction.\n \`actionId: ${actionId}\``,
+      );
     }
     }
 
 
     // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
     // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
-    return handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil);
+    return handler.handleInteractions(
+      client,
+      interactionPayload,
+      interactionPayloadAccessor,
+      handlerMethodName,
+      respondUtil,
+    );
   }
   }
 
 
   async handleViewSubmissionRequest(
   async handleViewSubmissionRequest(
-      client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor, respondUtil: RespondUtil,
+    client,
+    interactionPayload: any,
+    interactionPayloadAccessor: InteractionPayloadAccessor,
+    respondUtil: RespondUtil,
   ): Promise<void> {
   ): Promise<void> {
-    const { callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
+    const { callbackId } =
+      interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const commandName = callbackId.split(':')[0];
     const commandName = callbackId.split(':')[0];
     const handlerMethodName = callbackId.split(':')[1];
     const handlerMethodName = callbackId.split(':')[1];
 
 
     const modulePath = `./slack-command-handler/${commandName}`;
     const modulePath = `./slack-command-handler/${commandName}`;
 
 
-    let handler;
+    let handler: any;
     try {
     try {
       handler = require(modulePath)(this.crowi);
       handler = require(modulePath)(this.crowi);
-    }
-    catch (err) {
-      throw new SlackCommandHandlerError(`No interaction.\n \`callbackId: ${callbackId}\``);
+    } catch (err) {
+      throw new SlackCommandHandlerError(
+        `No interaction.\n \`callbackId: ${callbackId}\``,
+      );
     }
     }
 
 
     // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
     // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
-    return handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil);
+    return handler.handleInteractions(
+      client,
+      interactionPayload,
+      interactionPayloadAccessor,
+      handlerMethodName,
+      respondUtil,
+    );
   }
   }
 
 
-  async handleEventsRequest(client: WebClient, growiBotEvent: GrowiBotEvent<any>, permission: EventActionsPermission, data?: any): Promise<void> {
+  async handleEventsRequest(
+    client: WebClient,
+    growiBotEvent: GrowiBotEvent<any>,
+    permission: EventActionsPermission,
+    data?: any,
+  ): Promise<void> {
     const { eventType } = growiBotEvent;
     const { eventType } = growiBotEvent;
     const { channel = '' } = growiBotEvent.event; // only channelId
     const { channel = '' } = growiBotEvent.event; // only channelId
 
 
@@ -320,7 +381,8 @@ export class SlackIntegrationService implements S2sMessageHandlable {
       return this.linkSharedHandler.handleEvent(client, growiBotEvent, data);
       return this.linkSharedHandler.handleEvent(client, growiBotEvent, data);
     }
     }
 
 
-    logger.error(`Any event actions are not permitted, or, a handler for '${eventType}' event is not implemented`);
+    logger.error(
+      `Any event actions are not permitted, or, a handler for '${eventType}' event is not implemented`,
+    );
   }
   }
-
 }
 }

+ 4 - 1
apps/app/src/server/service/socket-io/helper.ts

@@ -3,6 +3,9 @@ export const RoomPrefix = {
   PAGE: 'page',
   PAGE: 'page',
 };
 };
 
 
-export const getRoomNameWithId = (roomPrefix: string, roomId: string): string => {
+export const getRoomNameWithId = (
+  roomPrefix: string,
+  roomId: string,
+): string => {
   return `${roomPrefix}:${roomId}`;
   return `${roomPrefix}:${roomId}`;
 };
 };

+ 27 - 22
apps/app/src/server/service/socket-io/socket-io.ts

@@ -1,7 +1,6 @@
-import type { IncomingMessage } from 'http';
-
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import expressSession from 'express-session';
 import expressSession from 'express-session';
+import type { IncomingMessage } from 'http';
 import passport from 'passport';
 import passport from 'passport';
 import type { Namespace } from 'socket.io';
 import type { Namespace } from 'socket.io';
 import { Server } from 'socket.io';
 import { Server } from 'socket.io';
@@ -11,20 +10,16 @@ import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../../crowi';
 import type Crowi from '../../crowi';
 import { configManager } from '../config-manager';
 import { configManager } from '../config-manager';
-
-import { RoomPrefix, getRoomNameWithId } from './helper';
-
+import { getRoomNameWithId, RoomPrefix } from './helper';
 
 
 const logger = loggerFactory('growi:service:socket-io');
 const logger = loggerFactory('growi:service:socket-io');
 
 
-
 type RequestWithUser = IncomingMessage & { user: IUserHasId };
 type RequestWithUser = IncomingMessage & { user: IUserHasId };
 
 
 /**
 /**
  * Serve socket.io for server-to-client messaging
  * Serve socket.io for server-to-client messaging
  */
  */
 export class SocketIoService {
 export class SocketIoService {
-
   crowi: Crowi;
   crowi: Crowi;
 
 
   guestClients: Set<string>;
   guestClients: Set<string>;
@@ -33,14 +28,13 @@ export class SocketIoService {
 
 
   adminNamespace: Namespace;
   adminNamespace: Namespace;
 
 
-
   constructor(crowi: Crowi) {
   constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
     this.guestClients = new Set();
     this.guestClients = new Set();
   }
   }
 
 
   get isInitialized(): boolean {
   get isInitialized(): boolean {
-    return (this.io != null);
+    return this.io != null;
   }
   }
 
 
   // Since the Order is important, attachServer() should be async
   // Since the Order is important, attachServer() should be async
@@ -95,9 +89,13 @@ export class SocketIoService {
    * use loginRequired middleware
    * use loginRequired middleware
    */
    */
   setupLoginRequiredMiddleware() {
   setupLoginRequiredMiddleware() {
-    const loginRequired = require('../../middlewares/login-required')(this.crowi, true, (req, res, next) => {
-      next(new Error('Login is required to connect.'));
-    });
+    const loginRequired = require('../../middlewares/login-required')(
+      this.crowi,
+      true,
+      (req, res, next) => {
+        next(new Error('Login is required to connect.'));
+      },
+    );
 
 
     // convert Connect/Express middleware to Socket.io middleware
     // convert Connect/Express middleware to Socket.io middleware
     this.io.use((socket, next) => {
     this.io.use((socket, next) => {
@@ -109,9 +107,12 @@ export class SocketIoService {
    * use adminRequired middleware
    * use adminRequired middleware
    */
    */
   setupAdminRequiredMiddleware() {
   setupAdminRequiredMiddleware() {
-    const adminRequired = require('../../middlewares/admin-required')(this.crowi, (req, res, next) => {
-      next(new Error('Admin priviledge is required to connect.'));
-    });
+    const adminRequired = require('../../middlewares/admin-required')(
+      this.crowi,
+      (req, res, next) => {
+        next(new Error('Admin priviledge is required to connect.'));
+      },
+    );
 
 
     // convert Connect/Express middleware to Socket.io middleware
     // convert Connect/Express middleware to Socket.io middleware
     this.getAdminSocket().use((socket, next) => {
     this.getAdminSocket().use((socket, next) => {
@@ -175,9 +176,11 @@ export class SocketIoService {
       const clients = await this.getAdminSocket().fetchSockets();
       const clients = await this.getAdminSocket().fetchSockets();
       const clientsCount = clients.length;
       const clientsCount = clients.length;
 
 
-      logger.debug('Current count of clients for \'/admin\':', clientsCount);
+      logger.debug("Current count of clients for '/admin':", clientsCount);
 
 
-      const limit = configManager.getConfig('s2cMessagingPubsub:connectionsLimitForAdmin');
+      const limit = configManager.getConfig(
+        's2cMessagingPubsub:connectionsLimitForAdmin',
+      );
       if (limit <= clientsCount) {
       if (limit <= clientsCount) {
         const msg = `The connection was refused because the current count of clients for '/admin' is ${clientsCount} and exceeds the limit`;
         const msg = `The connection was refused because the current count of clients for '/admin' is ${clientsCount} and exceeds the limit`;
         logger.warn(msg);
         logger.warn(msg);
@@ -190,13 +193,14 @@ export class SocketIoService {
   }
   }
 
 
   async checkConnectionLimitsForGuest(socket, next) {
   async checkConnectionLimitsForGuest(socket, next) {
-
     if (socket.request.user == null) {
     if (socket.request.user == null) {
       const clientsCount = this.guestClients.size;
       const clientsCount = this.guestClients.size;
 
 
       logger.debug('Current count of clients for guests:', clientsCount);
       logger.debug('Current count of clients for guests:', clientsCount);
 
 
-      const limit = configManager.getConfig('s2cMessagingPubsub:connectionsLimitForGuest');
+      const limit = configManager.getConfig(
+        's2cMessagingPubsub:connectionsLimitForGuest',
+      );
       if (limit <= clientsCount) {
       if (limit <= clientsCount) {
         const msg = `The connection was refused because the current count of clients for guests is ${clientsCount} and exceeds the limit`;
         const msg = `The connection was refused because the current count of clients for guests is ${clientsCount} and exceeds the limit`;
         logger.warn(msg);
         logger.warn(msg);
@@ -221,9 +225,11 @@ export class SocketIoService {
     const clients = await this.getDefaultSocket().fetchSockets();
     const clients = await this.getDefaultSocket().fetchSockets();
     const clientsCount = clients.length;
     const clientsCount = clients.length;
 
 
-    logger.debug('Current count of clients for \'/\':', clientsCount);
+    logger.debug("Current count of clients for '/':", clientsCount);
 
 
-    const limit = configManager.getConfig('s2cMessagingPubsub:connectionsLimit');
+    const limit = configManager.getConfig(
+      's2cMessagingPubsub:connectionsLimit',
+    );
     if (limit <= clientsCount) {
     if (limit <= clientsCount) {
       const msg = `The connection was refused because the current count of clients for '/' is ${clientsCount} and exceeds the limit`;
       const msg = `The connection was refused because the current count of clients for '/' is ${clientsCount} and exceeds the limit`;
       logger.warn(msg);
       logger.warn(msg);
@@ -233,5 +239,4 @@ export class SocketIoService {
 
 
     next();
     next();
   }
   }
-
 }
 }

+ 25 - 15
apps/app/src/server/service/system-events/sync-page-status.ts

@@ -5,9 +5,11 @@ import { S2cMessagePageUpdated } from '../../models/vo/s2c-message';
 import S2sMessage from '../../models/vo/s2s-message';
 import S2sMessage from '../../models/vo/s2s-message';
 import type { S2sMessagingService } from '../s2s-messaging/base';
 import type { S2sMessagingService } from '../s2s-messaging/base';
 import type { S2sMessageHandlable } from '../s2s-messaging/handlable';
 import type { S2sMessageHandlable } from '../s2s-messaging/handlable';
-import { RoomPrefix, getRoomNameWithId } from '../socket-io/helper';
+import { getRoomNameWithId, RoomPrefix } from '../socket-io/helper';
 
 
-const logger = loggerFactory('growi:service:system-events:SyncPageStatusService');
+const logger = loggerFactory(
+  'growi:service:system-events:SyncPageStatusService',
+);
 
 
 /**
 /**
  * This service notify page status
  * This service notify page status
@@ -23,7 +25,6 @@ const logger = loggerFactory('growi:service:system-events:SyncPageStatusService'
  *
  *
  */
  */
 class SyncPageStatusService implements S2sMessageHandlable {
 class SyncPageStatusService implements S2sMessageHandlable {
-
   crowi: Crowi;
   crowi: Crowi;
 
 
   s2sMessagingService!: S2sMessagingService;
   s2sMessagingService!: S2sMessagingService;
@@ -63,7 +64,9 @@ class SyncPageStatusService implements S2sMessageHandlable {
 
 
     // emit the updated information to clients
     // emit the updated information to clients
     if (socketIoService.isInitialized) {
     if (socketIoService.isInitialized) {
-      socketIoService.getDefaultSocket().emit(socketIoEventName, s2cMessageBody);
+      socketIoService
+        .getDefaultSocket()
+        .emit(socketIoEventName, s2cMessageBody);
     }
     }
   }
   }
 
 
@@ -71,13 +74,18 @@ class SyncPageStatusService implements S2sMessageHandlable {
     const { s2sMessagingService } = this;
     const { s2sMessagingService } = this;
 
 
     if (s2sMessagingService != null) {
     if (s2sMessagingService != null) {
-      const s2sMessage = new S2sMessage('pageStatusUpdated', { socketIoEventName, s2cMessageBody });
+      const s2sMessage = new S2sMessage('pageStatusUpdated', {
+        socketIoEventName,
+        s2cMessageBody,
+      });
 
 
       try {
       try {
         await s2sMessagingService.publish(s2sMessage);
         await s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      } catch (e) {
+        logger.error(
+          'Failed to publish update message with S2sMessagingService: ',
+          e.message,
+        );
       }
       }
     }
     }
   }
   }
@@ -87,12 +95,13 @@ class SyncPageStatusService implements S2sMessageHandlable {
 
 
     // register events
     // register events
     this.emitter.on('create', (page, user) => {
     this.emitter.on('create', (page, user) => {
-      logger.debug('\'create\' event emitted.');
+      logger.debug("'create' event emitted.");
 
 
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
 
 
       // emit to the room for each page
       // emit to the room for each page
-      socketIoService.getDefaultSocket()
+      socketIoService
+        .getDefaultSocket()
         .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
         .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
         .except(getRoomNameWithId(RoomPrefix.USER, user._id))
         .except(getRoomNameWithId(RoomPrefix.USER, user._id))
         .emit('page:create', { s2cMessagePageUpdated });
         .emit('page:create', { s2cMessagePageUpdated });
@@ -100,12 +109,13 @@ class SyncPageStatusService implements S2sMessageHandlable {
       this.publishToOtherServers('page:create', { s2cMessagePageUpdated });
       this.publishToOtherServers('page:create', { s2cMessagePageUpdated });
     });
     });
     this.emitter.on('update', (page, user) => {
     this.emitter.on('update', (page, user) => {
-      logger.debug('\'update\' event emitted.');
+      logger.debug("'update' event emitted.");
 
 
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
 
 
       // emit to the room for each page
       // emit to the room for each page
-      socketIoService.getDefaultSocket()
+      socketIoService
+        .getDefaultSocket()
         .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
         .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
         .except(getRoomNameWithId(RoomPrefix.USER, user._id))
         .except(getRoomNameWithId(RoomPrefix.USER, user._id))
         .emit('page:update', { s2cMessagePageUpdated });
         .emit('page:update', { s2cMessagePageUpdated });
@@ -113,12 +123,13 @@ class SyncPageStatusService implements S2sMessageHandlable {
       this.publishToOtherServers('page:update', { s2cMessagePageUpdated });
       this.publishToOtherServers('page:update', { s2cMessagePageUpdated });
     });
     });
     this.emitter.on('delete', (page, user) => {
     this.emitter.on('delete', (page, user) => {
-      logger.debug('\'delete\' event emitted.');
+      logger.debug("'delete' event emitted.");
 
 
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
 
 
       // emit to the room for each page
       // emit to the room for each page
-      socketIoService.getDefaultSocket()
+      socketIoService
+        .getDefaultSocket()
         .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
         .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
         .except(getRoomNameWithId(RoomPrefix.USER, user._id))
         .except(getRoomNameWithId(RoomPrefix.USER, user._id))
         .emit('page:delete', { s2cMessagePageUpdated });
         .emit('page:delete', { s2cMessagePageUpdated });
@@ -126,7 +137,6 @@ class SyncPageStatusService implements S2sMessageHandlable {
       this.publishToOtherServers('page:delete', { s2cMessagePageUpdated });
       this.publishToOtherServers('page:delete', { s2cMessagePageUpdated });
     });
     });
   }
   }
-
 }
 }
 
 
 module.exports = SyncPageStatusService;
 module.exports = SyncPageStatusService;

+ 101 - 35
apps/app/src/server/service/user-group.ts

@@ -1,33 +1,54 @@
-import type { IUser, IGrantedGroup } from '@growi/core';
+import type { IGrantedGroup, IUser } from '@growi/core';
 import type { DeleteResult } from 'mongodb';
 import type { DeleteResult } from 'mongodb';
 import mongoose, { type Model } from 'mongoose';
 import mongoose, { type Model } from 'mongoose';
 
 
 import type { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import type { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
-import type { UserGroupDocument, UserGroupModel } from '~/server/models/user-group';
+import type {
+  UserGroupDocument,
+  UserGroupModel,
+} from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
-import { excludeTestIdsFromTargetIds, includesObjectIds } from '~/server/util/compare-objectId';
+import {
+  excludeTestIdsFromTargetIds,
+  includesObjectIds,
+} from '~/server/util/compare-objectId';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../crowi';
 import type Crowi from '../crowi';
-import type { UserGroupRelationDocument, UserGroupRelationModel } from '../models/user-group-relation';
+import type {
+  UserGroupRelationDocument,
+  UserGroupRelationModel,
+} from '../models/user-group-relation';
 import UserGroupRelation from '../models/user-group-relation';
 import UserGroupRelation from '../models/user-group-relation';
 
 
-
 const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
 
 
 export interface IUserGroupService {
 export interface IUserGroupService {
   init(): Promise<void>;
   init(): Promise<void>;
-  updateGroup(id: ObjectIdLike, name?: string, description?: string, parentId?: ObjectIdLike | null, forceUpdateParents?: boolean): Promise<UserGroupDocument>;
-  removeCompletelyByRootGroupId(deleteRootGroupId: ObjectIdLike, action: string, user: IUser, transferToUserGroup?: IGrantedGroup): Promise<DeleteResult>;
-  removeUserByUsername(userGroupId: ObjectIdLike, username: string): Promise<{user: IUser, deletedGroupsCount: number}>;
+  updateGroup(
+    id: ObjectIdLike,
+    name?: string,
+    description?: string,
+    parentId?: ObjectIdLike | null,
+    forceUpdateParents?: boolean,
+  ): Promise<UserGroupDocument>;
+  removeCompletelyByRootGroupId(
+    deleteRootGroupId: ObjectIdLike,
+    action: string,
+    user: IUser,
+    transferToUserGroup?: IGrantedGroup,
+  ): Promise<DeleteResult>;
+  removeUserByUsername(
+    userGroupId: ObjectIdLike,
+    username: string,
+  ): Promise<{ user: IUser; deletedGroupsCount: number }>;
 }
 }
 
 
 /**
 /**
  * the service class of UserGroupService
  * the service class of UserGroupService
  */
  */
 class UserGroupService implements IUserGroupService {
 class UserGroupService implements IUserGroupService {
-
   crowi: Crowi;
   crowi: Crowi;
 
 
   constructor(crowi: Crowi) {
   constructor(crowi: Crowi) {
@@ -40,7 +61,13 @@ class UserGroupService implements IUserGroupService {
   }
   }
 
 
   // ref: https://dev.growi.org/61b2cdabaa330ce7d8152844
   // ref: https://dev.growi.org/61b2cdabaa330ce7d8152844
-  async updateGroup(id, name?: string, description?: string, parentId?: string | null, forceUpdateParents = false): Promise<UserGroupDocument> {
+  async updateGroup(
+    id,
+    name?: string,
+    description?: string,
+    parentId?: string | null,
+    forceUpdateParents = false,
+  ): Promise<UserGroupDocument> {
     const userGroup = await UserGroup.findById(id);
     const userGroup = await UserGroup.findById(id);
     if (userGroup == null) {
     if (userGroup == null) {
       throw new Error('The group does not exist');
       throw new Error('The group does not exist');
@@ -67,7 +94,8 @@ class UserGroupService implements IUserGroupService {
     /*
     /*
      * Update parent
      * Update parent
      */
      */
-    if (parentId === undefined) { // undefined will be ignored
+    if (parentId === undefined) {
+      // undefined will be ignored
       return userGroup.save();
       return userGroup.save();
     }
     }
 
 
@@ -77,10 +105,10 @@ class UserGroupService implements IUserGroupService {
       return userGroup.save();
       return userGroup.save();
     }
     }
 
 
-
     const parent = await UserGroup.findById(parentId);
     const parent = await UserGroup.findById(parentId);
 
 
-    if (parent == null) { // it should not be null
+    if (parent == null) {
+      // it should not be null
       throw Error('Parent group does not exist.');
       throw Error('Parent group does not exist.');
     }
     }
 
 
@@ -89,16 +117,26 @@ class UserGroupService implements IUserGroupService {
      */
      */
 
 
     // throw if parent was in self and its descendants
     // throw if parent was in self and its descendants
-    const descendantsWithTarget = await UserGroup.findGroupsWithDescendantsRecursively([userGroup]);
-    if (includesObjectIds(descendantsWithTarget.map(d => d._id), [parent._id])) {
+    const descendantsWithTarget =
+      await UserGroup.findGroupsWithDescendantsRecursively([userGroup]);
+    if (
+      includesObjectIds(
+        descendantsWithTarget.map((d) => d._id),
+        [parent._id],
+      )
+    ) {
       throw Error('It is not allowed to choose parent from descendant groups.');
       throw Error('It is not allowed to choose parent from descendant groups.');
     }
     }
 
 
     // find users for comparison
     // find users for comparison
-    const [targetGroupUsers, parentGroupUsers] = await Promise.all(
-      [UserGroupRelation.findUserIdsByGroupId(userGroup._id), UserGroupRelation.findUserIdsByGroupId(parent._id)],
+    const [targetGroupUsers, parentGroupUsers] = await Promise.all([
+      UserGroupRelation.findUserIdsByGroupId(userGroup._id),
+      UserGroupRelation.findUserIdsByGroupId(parent._id),
+    ]);
+    const usersBelongsToTargetButNotParent = excludeTestIdsFromTargetIds(
+      targetGroupUsers,
+      parentGroupUsers,
     );
     );
-    const usersBelongsToTargetButNotParent = excludeTestIdsFromTargetIds(targetGroupUsers, parentGroupUsers);
 
 
     // save if no users exist in both target and parent groups
     // save if no users exist in both target and parent groups
     if (targetGroupUsers.length === 0 && parentGroupUsers.length === 0) {
     if (targetGroupUsers.length === 0 && parentGroupUsers.length === 0) {
@@ -108,16 +146,22 @@ class UserGroupService implements IUserGroupService {
 
 
     // add the target group's users to all ancestors
     // add the target group's users to all ancestors
     if (forceUpdateParents) {
     if (forceUpdateParents) {
-      const ancestorGroups = await UserGroup.findGroupsWithAncestorsRecursively(parent);
-      const ancestorGroupIds = ancestorGroups.map(group => group._id);
-
-      await UserGroupRelation.createByGroupIdsAndUserIds(ancestorGroupIds, usersBelongsToTargetButNotParent);
+      const ancestorGroups =
+        await UserGroup.findGroupsWithAncestorsRecursively(parent);
+      const ancestorGroupIds = ancestorGroups.map((group) => group._id);
+
+      await UserGroupRelation.createByGroupIdsAndUserIds(
+        ancestorGroupIds,
+        usersBelongsToTargetButNotParent,
+      );
     }
     }
     // throw if any of users in the target group is NOT included in the parent group
     // throw if any of users in the target group is NOT included in the parent group
     else {
     else {
       const isUpdatable = usersBelongsToTargetButNotParent.length === 0;
       const isUpdatable = usersBelongsToTargetButNotParent.length === 0;
       if (!isUpdatable) {
       if (!isUpdatable) {
-        throw Error('The parent group does not contain the users in this group.');
+        throw Error(
+          'The parent group does not contain the users in this group.',
+        );
       }
       }
     }
     }
 
 
@@ -126,28 +170,45 @@ class UserGroupService implements IUserGroupService {
   }
   }
 
 
   async removeCompletelyByRootGroupId(
   async removeCompletelyByRootGroupId(
-      deleteRootGroupId, action: PageActionOnGroupDelete, user, transferToUserGroup?: IGrantedGroup,
-      userGroupModel: Model<UserGroupDocument> & UserGroupModel = UserGroup,
-      userGroupRelationModel: Model<UserGroupRelationDocument> & UserGroupRelationModel = UserGroupRelation,
+    deleteRootGroupId,
+    action: PageActionOnGroupDelete,
+    user,
+    transferToUserGroup?: IGrantedGroup,
+    userGroupModel: Model<UserGroupDocument> & UserGroupModel = UserGroup,
+    userGroupRelationModel: Model<UserGroupRelationDocument> &
+      UserGroupRelationModel = UserGroupRelation,
   ): Promise<DeleteResult> {
   ): Promise<DeleteResult> {
     const rootGroup = await userGroupModel.findById(deleteRootGroupId);
     const rootGroup = await userGroupModel.findById(deleteRootGroupId);
     if (rootGroup == null) {
     if (rootGroup == null) {
-      throw new Error(`UserGroup data does not exist. id: ${deleteRootGroupId}`);
+      throw new Error(
+        `UserGroup data does not exist. id: ${deleteRootGroupId}`,
+      );
     }
     }
 
 
-    const groupsToDelete = await userGroupModel.findGroupsWithDescendantsRecursively([rootGroup]);
+    const groupsToDelete =
+      await userGroupModel.findGroupsWithDescendantsRecursively([rootGroup]);
 
 
     // 1. update page & remove all groups
     // 1. update page & remove all groups
-    await this.crowi.pageService.handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroup, user);
+    await this.crowi.pageService.handlePrivatePagesForGroupsToDelete(
+      groupsToDelete,
+      action,
+      transferToUserGroup,
+      user,
+    );
     // 2. remove all groups
     // 2. remove all groups
-    const deletedGroups = await userGroupModel.deleteMany({ _id: { $in: groupsToDelete.map(g => g._id) } });
+    const deletedGroups = await userGroupModel.deleteMany({
+      _id: { $in: groupsToDelete.map((g) => g._id) },
+    });
     // 3. remove all relations
     // 3. remove all relations
     await userGroupRelationModel.removeAllByUserGroups(groupsToDelete);
     await userGroupRelationModel.removeAllByUserGroups(groupsToDelete);
 
 
     return deletedGroups;
     return deletedGroups;
   }
   }
 
 
-  async removeUserByUsername(userGroupId: ObjectIdLike, username: string): Promise<{user: IUser, deletedGroupsCount: number}> {
+  async removeUserByUsername(
+    userGroupId: ObjectIdLike,
+    username: string,
+  ): Promise<{ user: IUser; deletedGroupsCount: number }> {
     const User = mongoose.model<IUser, { findUserByUsername }>('User');
     const User = mongoose.model<IUser, { findUserByUsername }>('User');
 
 
     const [userGroup, user] = await Promise.all([
     const [userGroup, user] = await Promise.all([
@@ -155,14 +216,19 @@ class UserGroupService implements IUserGroupService {
       User.findUserByUsername(username),
       User.findUserByUsername(username),
     ]);
     ]);
 
 
-    const groupsOfRelationsToDelete = userGroup != null ? await UserGroup.findGroupsWithDescendantsRecursively([userGroup]) : [];
-    const relatedGroupIdsToDelete = groupsOfRelationsToDelete.map(g => g._id);
+    const groupsOfRelationsToDelete =
+      userGroup != null
+        ? await UserGroup.findGroupsWithDescendantsRecursively([userGroup])
+        : [];
+    const relatedGroupIdsToDelete = groupsOfRelationsToDelete.map((g) => g._id);
 
 
-    const deleteManyRes = await UserGroupRelation.deleteMany({ relatedUser: user._id, relatedGroup: { $in: relatedGroupIdsToDelete } });
+    const deleteManyRes = await UserGroupRelation.deleteMany({
+      relatedUser: user._id,
+      relatedGroup: { $in: relatedGroupIdsToDelete },
+    });
 
 
     return { user, deletedGroupsCount: deleteManyRes.deletedCount };
     return { user, deletedGroupsCount: deleteManyRes.deletedCount };
   }
   }
-
 }
 }
 
 
 export default UserGroupService;
 export default UserGroupService;

+ 31 - 14
apps/app/src/server/service/user-notification/index.ts

@@ -3,10 +3,9 @@ import type { IRevisionHasId } from '@growi/core';
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
 import { toArrayFromCsv } from '~/utils/to-array-from-csv';
 import { toArrayFromCsv } from '~/utils/to-array-from-csv';
 
 
-
 import {
 import {
-  prepareSlackMessageForPage,
   prepareSlackMessageForComment,
   prepareSlackMessageForComment,
+  prepareSlackMessageForPage,
 } from '../../util/slack';
 } from '../../util/slack';
 import { growiInfoService } from '../growi-info';
 import { growiInfoService } from '../growi-info';
 
 
@@ -14,7 +13,6 @@ import { growiInfoService } from '../growi-info';
  * service class of UserNotification
  * service class of UserNotification
  */
  */
 export class UserNotificationService {
 export class UserNotificationService {
-
   crowi: Crowi;
   crowi: Crowi;
 
 
   constructor(crowi: Crowi) {
   constructor(crowi: Crowi) {
@@ -34,10 +32,15 @@ export class UserNotificationService {
    * @param {Comment} comment
    * @param {Comment} comment
    */
    */
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  async fire(page, user, slackChannelsStr, mode, option?: { previousRevision: IRevisionHasId }, comment = {}): Promise<PromiseSettledResult<any>[]> {
-    const {
-      appService, slackIntegrationService,
-    } = this.crowi;
+  async fire(
+    page,
+    user,
+    slackChannelsStr,
+    mode,
+    option?: { previousRevision: IRevisionHasId },
+    comment = {},
+  ): Promise<PromiseSettledResult<any>[]> {
+    const { appService, slackIntegrationService } = this.crowi;
 
 
     if (!slackIntegrationService.isSlackConfigured) {
     if (!slackIntegrationService.isSlackConfigured) {
       throw new Error('slackIntegrationService has not been set up');
       throw new Error('slackIntegrationService has not been set up');
@@ -49,18 +52,33 @@ export class UserNotificationService {
     const { previousRevision } = option ?? {};
     const { previousRevision } = option ?? {};
 
 
     // "dev,slacktest" => [dev,slacktest]
     // "dev,slacktest" => [dev,slacktest]
-    const slackChannels: (string|null)[] = toArrayFromCsv(slackChannelsStr);
+    const slackChannels: (string | null)[] = toArrayFromCsv(slackChannelsStr);
 
 
     const appTitle = appService.getAppTitle();
     const appTitle = appService.getAppTitle();
     const siteUrl = growiInfoService.getSiteUrl();
     const siteUrl = growiInfoService.getSiteUrl();
 
 
-    const promises = slackChannels.map(async(chan) => {
+    const promises = slackChannels.map(async (chan) => {
+      // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
       let messageObj;
       let messageObj;
       if (mode === 'comment') {
       if (mode === 'comment') {
-        messageObj = prepareSlackMessageForComment(comment, user, appTitle, siteUrl, chan, page.path);
-      }
-      else {
-        messageObj = prepareSlackMessageForPage(page, user, appTitle, siteUrl, chan, mode, previousRevision);
+        messageObj = prepareSlackMessageForComment(
+          comment,
+          user,
+          appTitle,
+          siteUrl,
+          chan,
+          page.path,
+        );
+      } else {
+        messageObj = prepareSlackMessageForPage(
+          page,
+          user,
+          appTitle,
+          siteUrl,
+          chan,
+          mode,
+          previousRevision,
+        );
       }
       }
 
 
       return slackIntegrationService.postMessage(messageObj);
       return slackIntegrationService.postMessage(messageObj);
@@ -68,5 +86,4 @@ export class UserNotificationService {
 
 
     return Promise.allSettled(promises);
     return Promise.allSettled(promises);
   }
   }
-
 }
 }

+ 2 - 4
apps/app/src/server/service/yjs/create-indexes.ts

@@ -4,8 +4,7 @@ import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:service:yjs:create-indexes');
 const logger = loggerFactory('growi:service:yjs:create-indexes');
 
 
-export const createIndexes = async(collectionName: string): Promise<void> => {
-
+export const createIndexes = async (collectionName: string): Promise<void> => {
   const collection = mongoose.connection.collection(collectionName);
   const collection = mongoose.connection.collection(collectionName);
 
 
   try {
   try {
@@ -35,8 +34,7 @@ export const createIndexes = async(collectionName: string): Promise<void> => {
         },
         },
       },
       },
     ]);
     ]);
-  }
-  catch (err) {
+  } catch (err) {
     logger.error('Failed to create Index', err);
     logger.error('Failed to create Index', err);
     throw err;
     throw err;
   }
   }

+ 12 - 5
apps/app/src/server/service/yjs/create-mongodb-persistence.ts

@@ -12,10 +12,12 @@ const logger = loggerFactory('growi:service:yjs:create-mongodb-persistence');
  * @param mdb
  * @param mdb
  * @returns
  * @returns
  */
  */
-export const createMongoDBPersistence = (mdb: MongodbPersistence): Persistence => {
+export const createMongoDBPersistence = (
+  mdb: MongodbPersistence,
+): Persistence => {
   const persistece: Persistence = {
   const persistece: Persistence = {
     provider: mdb,
     provider: mdb,
-    bindState: async(docName, ydoc) => {
+    bindState: async (docName, ydoc) => {
       logger.debug('bindState', { docName });
       logger.debug('bindState', { docName });
 
 
       const persistedYdoc = await mdb.getYDoc(docName);
       const persistedYdoc = await mdb.getYDoc(docName);
@@ -25,7 +27,12 @@ export const createMongoDBPersistence = (mdb: MongodbPersistence): Persistence =
       const diff = Y.encodeStateAsUpdate(ydoc, persistedStateVector);
       const diff = Y.encodeStateAsUpdate(ydoc, persistedStateVector);
 
 
       // store the new data in db (if there is any: empty update is an array of 0s)
       // store the new data in db (if there is any: empty update is an array of 0s)
-      if (diff.reduce((previousValue, currentValue) => previousValue + currentValue, 0) > 0) {
+      if (
+        diff.reduce(
+          (previousValue, currentValue) => previousValue + currentValue,
+          0,
+        ) > 0
+      ) {
         mdb.storeUpdate(docName, diff);
         mdb.storeUpdate(docName, diff);
         mdb.setTypedMeta(docName, 'updatedAt', Date.now());
         mdb.setTypedMeta(docName, 'updatedAt', Date.now());
       }
       }
@@ -34,7 +41,7 @@ export const createMongoDBPersistence = (mdb: MongodbPersistence): Persistence =
       Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc));
       Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc));
 
 
       // store updates of the document in db
       // store updates of the document in db
-      ydoc.on('update', async(update) => {
+      ydoc.on('update', async (update) => {
         mdb.storeUpdate(docName, update);
         mdb.storeUpdate(docName, update);
         mdb.setTypedMeta(docName, 'updatedAt', Date.now());
         mdb.setTypedMeta(docName, 'updatedAt', Date.now());
       });
       });
@@ -42,7 +49,7 @@ export const createMongoDBPersistence = (mdb: MongodbPersistence): Persistence =
       // cleanup some memory
       // cleanup some memory
       persistedYdoc.destroy();
       persistedYdoc.destroy();
     },
     },
-    writeState: async(docName) => {
+    writeState: async (docName) => {
       logger.debug('writeState', { docName });
       logger.debug('writeState', { docName });
       // This is called when all connections to the document are closed.
       // This is called when all connections to the document are closed.
 
 

+ 14 - 8
apps/app/src/server/service/yjs/extended/mongodb-persistence.ts

@@ -1,19 +1,25 @@
 import { MongodbPersistence as Original } from 'y-mongodb-provider';
 import { MongodbPersistence as Original } from 'y-mongodb-provider';
 
 
 export type MetadataTypesMap = {
 export type MetadataTypesMap = {
-  updatedAt: number,
-}
+  updatedAt: number;
+};
 type MetadataKeys = keyof MetadataTypesMap;
 type MetadataKeys = keyof MetadataTypesMap;
 
 
-
 export class MongodbPersistence extends Original {
 export class MongodbPersistence extends Original {
-
-  async setTypedMeta<K extends MetadataKeys>(docName: string, key: K, value: MetadataTypesMap[K]): Promise<void> {
+  async setTypedMeta<K extends MetadataKeys>(
+    docName: string,
+    key: K,
+    value: MetadataTypesMap[K],
+  ): Promise<void> {
     return this.setMeta(docName, key, value);
     return this.setMeta(docName, key, value);
   }
   }
 
 
-  async getTypedMeta<K extends MetadataKeys>(docName: string, key: K): Promise<MetadataTypesMap[K] | undefined> {
-    return await this.getMeta(docName, key) as MetadataTypesMap[K] | undefined;
+  async getTypedMeta<K extends MetadataKeys>(
+    docName: string,
+    key: K,
+  ): Promise<MetadataTypesMap[K] | undefined> {
+    return (await this.getMeta(docName, key)) as
+      | MetadataTypesMap[K]
+      | undefined;
   }
   }
-
 }
 }

+ 34 - 24
apps/app/src/server/service/yjs/sync-ydoc.ts

@@ -1,20 +1,18 @@
 import { Origin, YDocStatus } from '@growi/core';
 import { Origin, YDocStatus } from '@growi/core';
-import { type Delta } from '@growi/editor';
+import type { Delta } from '@growi/editor';
 import type { Document } from 'y-socket.io/dist/server';
 import type { Document } from 'y-socket.io/dist/server';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { Revision } from '../../models/revision';
 import { Revision } from '../../models/revision';
 import { normalizeLatestRevisionIfBroken } from '../revision/normalize-latest-revision-if-broken';
 import { normalizeLatestRevisionIfBroken } from '../revision/normalize-latest-revision-if-broken';
-
 import type { MongodbPersistence } from './extended/mongodb-persistence';
 import type { MongodbPersistence } from './extended/mongodb-persistence';
 
 
 const logger = loggerFactory('growi:service:yjs:sync-ydoc');
 const logger = loggerFactory('growi:service:yjs:sync-ydoc');
 
 
-
 type Context = {
 type Context = {
-  ydocStatus: YDocStatus,
-}
+  ydocStatus: YDocStatus;
+};
 
 
 /**
 /**
  * Sync the text and the meta data with the latest revision body
  * Sync the text and the meta data with the latest revision body
@@ -22,30 +20,35 @@ type Context = {
  * @param doc
  * @param doc
  * @param context true to force sync
  * @param context true to force sync
  */
  */
-export const syncYDoc = async(mdb: MongodbPersistence, doc: Document, context: true | Context): Promise<void> => {
+export const syncYDoc = async (
+  mdb: MongodbPersistence,
+  doc: Document,
+  context: true | Context,
+): Promise<void> => {
   const pageId = doc.name;
   const pageId = doc.name;
 
 
   // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
   // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
   await normalizeLatestRevisionIfBroken(pageId);
   await normalizeLatestRevisionIfBroken(pageId);
 
 
-  const revision = await Revision
-    .findOne(
-      // filter
-      { pageId },
-      // projection
-      { body: 1, createdAt: 1, origin: 1 },
-      // options
-      { sort: { createdAt: -1 } },
-    )
-    .lean();
+  const revision = await Revision.findOne(
+    // filter
+    { pageId },
+    // projection
+    { body: 1, createdAt: 1, origin: 1 },
+    // options
+    { sort: { createdAt: -1 } },
+  ).lean();
 
 
   if (revision == null) {
   if (revision == null) {
-    logger.warn(`Synchronization has been canceled since the revision of the page ('${pageId}') could not be found`);
+    logger.warn(
+      `Synchronization has been canceled since the revision of the page ('${pageId}') could not be found`,
+    );
     return;
     return;
   }
   }
 
 
-  const shouldSync = context === true
-    || (() => {
+  const shouldSync =
+    context === true ||
+    (() => {
       switch (context.ydocStatus) {
       switch (context.ydocStatus) {
         case YDocStatus.NEW:
         case YDocStatus.NEW:
           return true;
           return true;
@@ -58,7 +61,9 @@ export const syncYDoc = async(mdb: MongodbPersistence, doc: Document, context: t
     })();
     })();
 
 
   if (shouldSync) {
   if (shouldSync) {
-    logger.debug(`YDoc for the page ('${pageId}') is synced with the latest revision body`);
+    logger.debug(
+      `YDoc for the page ('${pageId}') is synced with the latest revision body`,
+    );
 
 
     const ytext = doc.getText('codemirror');
     const ytext = doc.getText('codemirror');
     const delta: Delta = [];
     const delta: Delta = [];
@@ -73,11 +78,16 @@ export const syncYDoc = async(mdb: MongodbPersistence, doc: Document, context: t
     ytext.applyDelta(delta, { sanitize: false });
     ytext.applyDelta(delta, { sanitize: false });
   }
   }
 
 
-  const shouldSyncMeta = context === true
-    || context.ydocStatus === YDocStatus.NEW
-    || context.ydocStatus === YDocStatus.OUTDATED;
+  const shouldSyncMeta =
+    context === true ||
+    context.ydocStatus === YDocStatus.NEW ||
+    context.ydocStatus === YDocStatus.OUTDATED;
 
 
   if (shouldSyncMeta) {
   if (shouldSyncMeta) {
-    mdb.setMeta(doc.name, 'updatedAt', revision.createdAt.getTime() ?? Date.now());
+    mdb.setMeta(
+      doc.name,
+      'updatedAt',
+      revision.createdAt.getTime() ?? Date.now(),
+    );
   }
   }
 };
 };

+ 26 - 26
apps/app/src/server/service/yjs/yjs.integ.ts

@@ -4,12 +4,10 @@ import type { Server } from 'socket.io';
 import { mock } from 'vitest-mock-extended';
 import { mock } from 'vitest-mock-extended';
 
 
 import { Revision } from '../../models/revision';
 import { Revision } from '../../models/revision';
-
 import type { MongodbPersistence } from './extended/mongodb-persistence';
 import type { MongodbPersistence } from './extended/mongodb-persistence';
 import type { IYjsService } from './yjs';
 import type { IYjsService } from './yjs';
 import { getYjsService, initializeYjsService } from './yjs';
 import { getYjsService, initializeYjsService } from './yjs';
 
 
-
 vi.mock('y-socket.io/dist/server', () => {
 vi.mock('y-socket.io/dist/server', () => {
   const YSocketIO = vi.fn();
   const YSocketIO = vi.fn();
   YSocketIO.prototype.on = vi.fn();
   YSocketIO.prototype.on = vi.fn();
@@ -23,24 +21,21 @@ vi.mock('../revision/normalize-latest-revision-if-broken', () => ({
 
 
 const ObjectId = Types.ObjectId;
 const ObjectId = Types.ObjectId;
 
 
-
 const getPrivateMdbInstance = (yjsService: IYjsService): MongodbPersistence => {
 const getPrivateMdbInstance = (yjsService: IYjsService): MongodbPersistence => {
   // eslint-disable-next-line dot-notation
   // eslint-disable-next-line dot-notation
   return yjsService['mdb'];
   return yjsService['mdb'];
 };
 };
 
 
 describe('YjsService', () => {
 describe('YjsService', () => {
-
   describe('getYDocStatus()', () => {
   describe('getYDocStatus()', () => {
-
-    beforeAll(async() => {
+    beforeAll(async () => {
       const ioMock = mock<Server>();
       const ioMock = mock<Server>();
 
 
       // initialize
       // initialize
       initializeYjsService(ioMock);
       initializeYjsService(ioMock);
     });
     });
 
 
-    afterAll(async() => {
+    afterAll(async () => {
       // flush revisions
       // flush revisions
       await Revision.deleteMany({});
       await Revision.deleteMany({});
 
 
@@ -50,7 +45,7 @@ describe('YjsService', () => {
       await privateMdb.flushDB();
       await privateMdb.flushDB();
     });
     });
 
 
-    it('returns ISOLATED when neither revisions nor YDocs exists', async() => {
+    it('returns ISOLATED when neither revisions nor YDocs exists', async () => {
       // arrange
       // arrange
       const yjsService = getYjsService();
       const yjsService = getYjsService();
 
 
@@ -63,7 +58,7 @@ describe('YjsService', () => {
       expect(result).toBe(YDocStatus.ISOLATED);
       expect(result).toBe(YDocStatus.ISOLATED);
     });
     });
 
 
-    it('returns ISOLATED when no revisions exist', async() => {
+    it('returns ISOLATED when no revisions exist', async () => {
       // arrange
       // arrange
       const yjsService = getYjsService();
       const yjsService = getYjsService();
 
 
@@ -79,15 +74,13 @@ describe('YjsService', () => {
       expect(result).toBe(YDocStatus.ISOLATED);
       expect(result).toBe(YDocStatus.ISOLATED);
     });
     });
 
 
-    it('returns NEW when no YDocs exist', async() => {
+    it('returns NEW when no YDocs exist', async () => {
       // arrange
       // arrange
       const yjsService = getYjsService();
       const yjsService = getYjsService();
 
 
       const pageId = new ObjectId();
       const pageId = new ObjectId();
 
 
-      await Revision.insertMany([
-        { pageId, body: '' },
-      ]);
+      await Revision.insertMany([{ pageId, body: '' }]);
 
 
       // act
       // act
       const result = await yjsService.getYDocStatus(pageId.toString());
       const result = await yjsService.getYDocStatus(pageId.toString());
@@ -96,18 +89,20 @@ describe('YjsService', () => {
       expect(result).toBe(YDocStatus.NEW);
       expect(result).toBe(YDocStatus.NEW);
     });
     });
 
 
-    it('returns DRAFT when the newer YDocs exist', async() => {
+    it('returns DRAFT when the newer YDocs exist', async () => {
       // arrange
       // arrange
       const yjsService = getYjsService();
       const yjsService = getYjsService();
 
 
       const pageId = new ObjectId();
       const pageId = new ObjectId();
 
 
-      await Revision.insertMany([
-        { pageId, body: '' },
-      ]);
+      await Revision.insertMany([{ pageId, body: '' }]);
 
 
       const privateMdb = getPrivateMdbInstance(yjsService);
       const privateMdb = getPrivateMdbInstance(yjsService);
-      await privateMdb.setTypedMeta(pageId.toString(), 'updatedAt', (new Date(2034, 1, 1)).getTime());
+      await privateMdb.setTypedMeta(
+        pageId.toString(),
+        'updatedAt',
+        new Date(2034, 1, 1).getTime(),
+      );
 
 
       // act
       // act
       const result = await yjsService.getYDocStatus(pageId.toString());
       const result = await yjsService.getYDocStatus(pageId.toString());
@@ -116,7 +111,7 @@ describe('YjsService', () => {
       expect(result).toBe(YDocStatus.DRAFT);
       expect(result).toBe(YDocStatus.DRAFT);
     });
     });
 
 
-    it('returns SYNCED', async() => {
+    it('returns SYNCED', async () => {
       // arrange
       // arrange
       const yjsService = getYjsService();
       const yjsService = getYjsService();
 
 
@@ -127,7 +122,11 @@ describe('YjsService', () => {
       ]);
       ]);
 
 
       const privateMdb = getPrivateMdbInstance(yjsService);
       const privateMdb = getPrivateMdbInstance(yjsService);
-      await privateMdb.setTypedMeta(pageId.toString(), 'updatedAt', (new Date(2025, 1, 1)).getTime());
+      await privateMdb.setTypedMeta(
+        pageId.toString(),
+        'updatedAt',
+        new Date(2025, 1, 1).getTime(),
+      );
 
 
       // act
       // act
       const result = await yjsService.getYDocStatus(pageId.toString());
       const result = await yjsService.getYDocStatus(pageId.toString());
@@ -136,18 +135,20 @@ describe('YjsService', () => {
       expect(result).toBe(YDocStatus.SYNCED);
       expect(result).toBe(YDocStatus.SYNCED);
     });
     });
 
 
-    it('returns OUTDATED when the latest revision is newer than meta data', async() => {
+    it('returns OUTDATED when the latest revision is newer than meta data', async () => {
       // arrange
       // arrange
       const yjsService = getYjsService();
       const yjsService = getYjsService();
 
 
       const pageId = new ObjectId();
       const pageId = new ObjectId();
 
 
-      await Revision.insertMany([
-        { pageId, body: '' },
-      ]);
+      await Revision.insertMany([{ pageId, body: '' }]);
 
 
       const privateMdb = getPrivateMdbInstance(yjsService);
       const privateMdb = getPrivateMdbInstance(yjsService);
-      await privateMdb.setTypedMeta(pageId.toString(), 'updatedAt', (new Date(2024, 1, 1)).getTime());
+      await privateMdb.setTypedMeta(
+        pageId.toString(),
+        'updatedAt',
+        new Date(2024, 1, 1).getTime(),
+      );
 
 
       // act
       // act
       const result = await yjsService.getYDocStatus(pageId.toString());
       const result = await yjsService.getYDocStatus(pageId.toString());
@@ -155,6 +156,5 @@ describe('YjsService', () => {
       // assert
       // assert
       expect(result).toBe(YDocStatus.OUTDATED);
       expect(result).toBe(YDocStatus.OUTDATED);
     });
     });
-
   });
   });
 });
 });

+ 46 - 44
apps/app/src/server/service/yjs/yjs.ts

@@ -1,57 +1,50 @@
-import type { IncomingMessage } from 'http';
-
-
 import type { IPage, IUserHasId } from '@growi/core';
 import type { IPage, IUserHasId } from '@growi/core';
 import { YDocStatus } from '@growi/core/dist/consts';
 import { YDocStatus } from '@growi/core/dist/consts';
+import type { IncomingMessage } from 'http';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 import type { Server } from 'socket.io';
 import type { Server } from 'socket.io';
 import type { Document } from 'y-socket.io/dist/server';
 import type { Document } from 'y-socket.io/dist/server';
-import { YSocketIO, type Document as Ydoc } from 'y-socket.io/dist/server';
+import { type Document as Ydoc, YSocketIO } from 'y-socket.io/dist/server';
 
 
 import { SocketEventName } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
 import type { SyncLatestRevisionBody } from '~/interfaces/yjs';
 import type { SyncLatestRevisionBody } from '~/interfaces/yjs';
-import { RoomPrefix, getRoomNameWithId } from '~/server/service/socket-io/helper';
+import {
+  getRoomNameWithId,
+  RoomPrefix,
+} from '~/server/service/socket-io/helper';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type { PageModel } from '../../models/page';
 import type { PageModel } from '../../models/page';
 import { Revision } from '../../models/revision';
 import { Revision } from '../../models/revision';
 import { normalizeLatestRevisionIfBroken } from '../revision/normalize-latest-revision-if-broken';
 import { normalizeLatestRevisionIfBroken } from '../revision/normalize-latest-revision-if-broken';
-
 import { createIndexes } from './create-indexes';
 import { createIndexes } from './create-indexes';
 import { createMongoDBPersistence } from './create-mongodb-persistence';
 import { createMongoDBPersistence } from './create-mongodb-persistence';
 import { MongodbPersistence } from './extended/mongodb-persistence';
 import { MongodbPersistence } from './extended/mongodb-persistence';
 import { syncYDoc } from './sync-ydoc';
 import { syncYDoc } from './sync-ydoc';
 
 
-
 const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings';
 const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings';
 const MONGODB_PERSISTENCE_FLUSH_SIZE = 100;
 const MONGODB_PERSISTENCE_FLUSH_SIZE = 100;
 
 
-
 const logger = loggerFactory('growi:service:yjs');
 const logger = loggerFactory('growi:service:yjs');
 
 
-
 type RequestWithUser = IncomingMessage & { user: IUserHasId };
 type RequestWithUser = IncomingMessage & { user: IUserHasId };
 
 
-
 export interface IYjsService {
 export interface IYjsService {
   getYDocStatus(pageId: string): Promise<YDocStatus>;
   getYDocStatus(pageId: string): Promise<YDocStatus>;
-  syncWithTheLatestRevisionForce(pageId: string, editingMarkdownLength?: number): Promise<SyncLatestRevisionBody>
+  syncWithTheLatestRevisionForce(
+    pageId: string,
+    editingMarkdownLength?: number,
+  ): Promise<SyncLatestRevisionBody>;
   getCurrentYdoc(pageId: string): Ydoc | undefined;
   getCurrentYdoc(pageId: string): Ydoc | undefined;
 }
 }
 
 
-
 class YjsService implements IYjsService {
 class YjsService implements IYjsService {
-
   private ysocketio: YSocketIO;
   private ysocketio: YSocketIO;
 
 
   private mdb: MongodbPersistence;
   private mdb: MongodbPersistence;
 
 
   constructor(io: Server) {
   constructor(io: Server) {
-
     const mdb = new MongodbPersistence(
     const mdb = new MongodbPersistence(
-      // ignore TS2345: Argument of type '{ client: any; db: any; }' is not assignable to parameter of type 'string'.
-      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-      // @ts-ignore
       {
       {
         // TODO: Required upgrading mongoose and unifying the versions of mongodb to omit 'as any'
         // TODO: Required upgrading mongoose and unifying the versions of mongodb to omit 'as any'
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -78,7 +71,7 @@ class YjsService implements IYjsService {
     // register middlewares
     // register middlewares
     this.registerAccessiblePageChecker(ysocketio);
     this.registerAccessiblePageChecker(ysocketio);
 
 
-    ysocketio.on('document-loaded', async(doc: Document) => {
+    ysocketio.on('document-loaded', async (doc: Document) => {
       const pageId = doc.name;
       const pageId = doc.name;
 
 
       const ydocStatus = await this.getYDocStatus(pageId);
       const ydocStatus = await this.getYDocStatus(pageId);
@@ -86,7 +79,7 @@ class YjsService implements IYjsService {
       syncYDoc(mdb, doc, { ydocStatus });
       syncYDoc(mdb, doc, { ydocStatus });
     });
     });
 
 
-    ysocketio.on('awareness-update', async(doc: Document) => {
+    ysocketio.on('awareness-update', async (doc: Document) => {
       const pageId = doc.name;
       const pageId = doc.name;
 
 
       if (pageId == null) return;
       if (pageId == null) return;
@@ -94,24 +87,29 @@ class YjsService implements IYjsService {
       const awarenessStateSize = doc.awareness.states.size;
       const awarenessStateSize = doc.awareness.states.size;
 
 
       // Triggered when awareness changes
       // Triggered when awareness changes
-      io
-        .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
-        .emit(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSize);
+      io.in(getRoomNameWithId(RoomPrefix.PAGE, pageId)).emit(
+        SocketEventName.YjsAwarenessStateSizeUpdated,
+        awarenessStateSize,
+      );
 
 
       // Triggered when the last user leaves the editor
       // Triggered when the last user leaves the editor
       if (awarenessStateSize === 0) {
       if (awarenessStateSize === 0) {
         const ydocStatus = await this.getYDocStatus(pageId);
         const ydocStatus = await this.getYDocStatus(pageId);
-        const hasYdocsNewerThanLatestRevision = ydocStatus === YDocStatus.DRAFT || ydocStatus === YDocStatus.ISOLATED;
+        const hasYdocsNewerThanLatestRevision =
+          ydocStatus === YDocStatus.DRAFT || ydocStatus === YDocStatus.ISOLATED;
 
 
-        io
-          .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
-          .emit(SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated, hasYdocsNewerThanLatestRevision);
+        io.in(getRoomNameWithId(RoomPrefix.PAGE, pageId)).emit(
+          SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated,
+          hasYdocsNewerThanLatestRevision,
+        );
       }
       }
     });
     });
-
   }
   }
 
 
-  private injectPersistence(ysocketio: YSocketIO, mdb: MongodbPersistence): void {
+  private injectPersistence(
+    ysocketio: YSocketIO,
+    mdb: MongodbPersistence,
+  ): void {
     const persistece = createMongoDBPersistence(mdb);
     const persistece = createMongoDBPersistence(mdb);
 
 
     // foce set to private property
     // foce set to private property
@@ -121,7 +119,7 @@ class YjsService implements IYjsService {
 
 
   private registerAccessiblePageChecker(ysocketio: YSocketIO): void {
   private registerAccessiblePageChecker(ysocketio: YSocketIO): void {
     // check accessible page
     // check accessible page
-    ysocketio.nsp?.use(async(socket, next) => {
+    ysocketio.nsp?.use(async (socket, next) => {
       // extract page id from namespace
       // extract page id from namespace
       const pageId = socket.nsp.name.replace(/\/yjs\|/, '');
       const pageId = socket.nsp.name.replace(/\/yjs\|/, '');
       const user = (socket.request as RequestWithUser).user; // should be injected by SocketIOService
       const user = (socket.request as RequestWithUser).user; // should be injected by SocketIOService
@@ -139,22 +137,23 @@ class YjsService implements IYjsService {
 
 
   public async getYDocStatus(pageId: string): Promise<YDocStatus> {
   public async getYDocStatus(pageId: string): Promise<YDocStatus> {
     const dumpLog = (status: YDocStatus, args?: { [key: string]: unknown }) => {
     const dumpLog = (status: YDocStatus, args?: { [key: string]: unknown }) => {
-      logger.debug(`getYDocStatus('${pageId}') detected '${status}'`, args ?? {});
+      logger.debug(
+        `getYDocStatus('${pageId}') detected '${status}'`,
+        args ?? {},
+      );
     };
     };
 
 
     // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
     // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
     await normalizeLatestRevisionIfBroken(pageId);
     await normalizeLatestRevisionIfBroken(pageId);
 
 
     // get the latest revision createdAt
     // get the latest revision createdAt
-    const result = await Revision
-      .findOne(
-        // filter
-        { pageId },
-        // projection
-        { createdAt: 1 },
-        { sort: { createdAt: -1 } },
-      )
-      .lean();
+    const result = await Revision.findOne(
+      // filter
+      { pageId },
+      // projection
+      { createdAt: 1 },
+      { sort: { createdAt: -1 } },
+    ).lean();
 
 
     if (result == null) {
     if (result == null) {
       dumpLog(YDocStatus.ISOLATED, { result });
       dumpLog(YDocStatus.ISOLATED, { result });
@@ -186,7 +185,10 @@ class YjsService implements IYjsService {
     return YDocStatus.OUTDATED;
     return YDocStatus.OUTDATED;
   }
   }
 
 
-  public async syncWithTheLatestRevisionForce(pageId: string, editingMarkdownLength?: number): Promise<SyncLatestRevisionBody> {
+  public async syncWithTheLatestRevisionForce(
+    pageId: string,
+    editingMarkdownLength?: number,
+  ): Promise<SyncLatestRevisionBody> {
     const doc = this.ysocketio.documents.get(pageId);
     const doc = this.ysocketio.documents.get(pageId);
 
 
     if (doc == null) {
     if (doc == null) {
@@ -198,9 +200,10 @@ class YjsService implements IYjsService {
 
 
     return {
     return {
       synced: true,
       synced: true,
-      isYjsDataBroken: editingMarkdownLength != null
-        ? editingMarkdownLength !== ytextLength
-        : undefined,
+      isYjsDataBroken:
+        editingMarkdownLength != null
+          ? editingMarkdownLength !== ytextLength
+          : undefined,
     };
     };
   }
   }
 
 
@@ -208,7 +211,6 @@ class YjsService implements IYjsService {
     const currentYdoc = this.ysocketio.documents.get(pageId);
     const currentYdoc = this.ysocketio.documents.get(pageId);
     return currentYdoc;
     return currentYdoc;
   }
   }
-
 }
 }
 
 
 let _instance: YjsService;
 let _instance: YjsService;

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