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

Merge branch 'dev/7.4.x' into fix/174481-accessing-admin-customize-error

Yuki Takei 6 месяцев назад
Родитель
Сommit
6801022488
38 измененных файлов с 3209 добавлено и 1900 удалено
  1. 6 0
      apps/app/.eslintrc.js
  2. 27 22
      apps/app/src/client/components/InvitedForm.tsx
  3. 2 2
      apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.tsx
  4. 8 2
      apps/app/src/features/search/client/components/SearchPage/SearchResultList.tsx
  5. 12 4
      apps/app/src/pages/[[...path]]/server-side-props.ts
  6. 6 1
      apps/app/src/pages/common-props/commons.ts
  7. 0 0
      apps/app/src/pages/me/[[...path]].page.tsx
  8. 50 37
      apps/app/src/server/routes/apiv3/activity.ts
  9. 26 15
      apps/app/src/server/routes/apiv3/admin-home.ts
  10. 157 94
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  11. 315 157
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  12. 26 13
      apps/app/src/server/routes/apiv3/healthcheck.ts
  13. 199 141
      apps/app/src/server/routes/apiv3/import.ts
  14. 88 59
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  15. 68 49
      apps/app/src/server/routes/apiv3/installer.ts
  16. 2 2
      apps/app/src/server/routes/apiv3/interfaces/apiv3-response.ts
  17. 48 36
      apps/app/src/server/routes/apiv3/invited.ts
  18. 116 63
      apps/app/src/server/routes/apiv3/page-listing.ts
  19. 458 234
      apps/app/src/server/routes/apiv3/pages/index.js
  20. 44 32
      apps/app/src/server/routes/apiv3/personal-setting/delete-access-token.ts
  21. 40 32
      apps/app/src/server/routes/apiv3/personal-setting/delete-all-access-tokens.ts
  22. 51 42
      apps/app/src/server/routes/apiv3/personal-setting/generate-access-token.ts
  23. 18 11
      apps/app/src/server/routes/apiv3/personal-setting/get-access-tokens.ts
  24. 277 157
      apps/app/src/server/routes/apiv3/personal-setting/index.js
  25. 13 5
      apps/app/src/server/routes/apiv3/security-settings/checkSetupStrategiesHasAdmin.ts
  26. 687 291
      apps/app/src/server/routes/apiv3/security-settings/index.js
  27. 145 84
      apps/app/src/server/routes/apiv3/user-activation.ts
  28. 46 26
      apps/app/src/server/routes/apiv3/user-activities.ts
  29. 51 45
      apps/app/src/server/routes/apiv3/user-ui-settings.ts
  30. 17 10
      apps/app/src/server/routes/apiv3/user/get-related-groups.ts
  31. 16 8
      apps/app/src/server/service/attachment.ts
  32. 44 51
      apps/app/src/server/service/file-uploader/aws/index.ts
  33. 21 28
      apps/app/src/server/service/file-uploader/azure.ts
  34. 19 12
      apps/app/src/server/service/file-uploader/file-uploader.ts
  35. 30 43
      apps/app/src/server/service/file-uploader/gcs/index.ts
  36. 42 47
      apps/app/src/server/service/file-uploader/gridfs.ts
  37. 31 44
      apps/app/src/server/service/file-uploader/local.ts
  38. 3 1
      biome.json

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

@@ -64,6 +64,12 @@ module.exports = {
     'src/server/routes/*.js',
     'src/server/routes/*.ts',
     'src/server/routes/attachment/**',
+    'src/server/routes/apiv3/interfaces/**',
+    'src/server/routes/apiv3/pages/**',
+    'src/server/routes/apiv3/user/**',
+    'src/server/routes/apiv3/personal-setting/**',
+    'src/server/routes/apiv3/security-settings/**',
+    'src/server/routes/apiv3/*.ts',
   ],
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript

+ 27 - 22
apps/app/src/client/components/InvitedForm.tsx

@@ -3,18 +3,23 @@ import React, { useCallback, useState, type JSX } from 'react';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
+import { useForm } from 'react-hook-form';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { useCurrentUser } from '~/states/global';
 
-
 type InvitedFormProps = {
   invitedFormUsername: string,
   invitedFormName: string,
 }
 
-export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
+type InvitedFormValues = {
+  name: string,
+  username: string,
+  password: string,
+};
 
+export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
   const { t } = useTranslation();
   const router = useRouter();
   const user = useCurrentUser();
@@ -23,22 +28,24 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
 
   const { invitedFormUsername, invitedFormName } = props;
 
-  const submitHandler = useCallback(async(e) => {
-    e.preventDefault();
+  const {
+    register,
+    handleSubmit,
+    formState: { isSubmitting },
+  } = useForm<InvitedFormValues>({
+    defaultValues: {
+      name: invitedFormName,
+      username: invitedFormUsername,
+    },
+  });
+
+  const submitHandler = useCallback(async(values: InvitedFormValues) => {
     setIsLoading(true);
 
-    const formData = e.target.elements;
-
-    const {
-      'invitedForm[name]': { value: name },
-      'invitedForm[password]': { value: password },
-      'invitedForm[username]': { value: username },
-    } = formData;
-
     const invitedForm = {
-      name,
-      password,
-      username,
+      name: values.name,
+      username: values.username,
+      password: values.password,
     };
 
     try {
@@ -79,7 +86,7 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
   return (
     <div className="nologin-dialog px-3 pb-3 mx-auto" id="nologin-dialog">
       { formNotification() }
-      <form role="form" onSubmit={submitHandler} id="invited-form">
+      <form role="form" onSubmit={handleSubmit(submitHandler)} id="invited-form">
         {/* Email Form */}
         <div className="input-group">
           <span className="input-group-text">
@@ -104,9 +111,8 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
             type="text"
             className="form-control"
             placeholder={t('User ID')}
-            name="invitedForm[username]"
-            value={invitedFormUsername}
             required
+            {...register('username', { required: true })}
           />
         </div>
         {/* Name Form */}
@@ -118,9 +124,8 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
             type="text"
             className="form-control"
             placeholder={t('Name')}
-            name="invitedForm[name]"
-            value={invitedFormName}
             required
+            {...register('name', { required: true })}
           />
         </div>
         {/* Password Form */}
@@ -132,14 +137,14 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
             type="password"
             className="form-control"
             placeholder={t('Password')}
-            name="invitedForm[password]"
             required
             minLength={6}
+            {...register('password', { required: true, minLength: 6 })}
           />
         </div>
         {/* Create Button */}
         <div className="input-group justify-content-center d-flex mt-4">
-          <button type="submit" className="btn btn-fill" id="register" disabled={isLoading}>
+          <button type="submit" className="btn btn-fill" id="register" disabled={isLoading || isSubmitting}>
             <span className="btn-label">
               {isLoading ? (
                 <LoadingSpinner />

+ 2 - 2
apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.tsx

@@ -1,4 +1,4 @@
-import { type JSX, memo, useCallback } from 'react';
+import { type FC, type JSX, memo, useCallback } from 'react';
 import Link from 'next/link';
 import urljoin from 'url-join';
 
@@ -17,7 +17,7 @@ type PagePathHierarchicalLinkProps = {
   isInnerElem?: boolean;
 };
 
-export const PagePathHierarchicalLink = memo(
+export const PagePathHierarchicalLink: FC<PagePathHierarchicalLinkProps> = memo(
   (props: PagePathHierarchicalLinkProps): JSX.Element => {
     const {
       linkedPagePath,

+ 8 - 2
apps/app/src/features/search/client/components/SearchPage/SearchResultList.tsx

@@ -1,4 +1,8 @@
-import type { ForwardRefRenderFunction } from 'react';
+import type {
+  ForwardRefExoticComponent,
+  ForwardRefRenderFunction,
+  RefAttributes,
+} from 'react';
 import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
 import {
   type IPageInfoForListing,
@@ -184,4 +188,6 @@ const SearchResultListSubstance: ForwardRefRenderFunction<
   );
 };
 
-export const SearchResultList = forwardRef(SearchResultListSubstance);
+export const SearchResultList: ForwardRefExoticComponent<
+  Props & RefAttributes<ISelectableAll>
+> = forwardRef<ISelectableAll, Props>(SearchResultListSubstance);

+ 12 - 4
apps/app/src/pages/[[...path]]/server-side-props.ts

@@ -83,12 +83,20 @@ export async function getServerSidePropsForInitial(
 export async function getServerSidePropsForSameRoute(
   context: GetServerSidePropsContext,
 ): Promise<GetServerSidePropsResult<Stage2EachProps>> {
-  // Get page data
-  const result = await getPageDataForSameRoute(context);
+  // -- TODO: :https://redmine.weseek.co.jp/issues/174725
+  // Remove getServerSideI18nProps from getServerSidePropsForSameRoute for performance improvement
+  const [i18nPropsResult, pageDataResult] = await Promise.all([
+    getServerSideI18nProps(context, ['translation']),
+    getPageDataForSameRoute(context),
+  ]);
 
   // -- TODO: persist activity
-
   // const mergedProps = await mergedResult.props;
   // await addActivity(context, getActivityAction(mergedProps));
-  return result;
+  const mergedResult = mergeGetServerSidePropsResults(
+    pageDataResult,
+    i18nPropsResult,
+  );
+
+  return mergedResult;
 }

+ 6 - 1
apps/app/src/pages/common-props/commons.ts

@@ -153,7 +153,12 @@ export const getServerSideCommonEachProps = async (
 
   let currentUser: IUserHasId | undefined;
   if (user != null) {
-    currentUser = user.toObject();
+    const User = crowi.model('User');
+    const userData = await User.findById(user.id).populate({
+      path: 'imageAttachment',
+      select: 'filePathProxied',
+    });
+    currentUser = userData.toObject();
   }
 
   // Redirect destination for page transition by next/link

+ 0 - 0
apps/app/src/pages/me/index.page.tsx → apps/app/src/pages/me/[[...path]].page.tsx


+ 50 - 37
apps/app/src/server/routes/apiv3/activity.ts

@@ -1,11 +1,11 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
-import { parseISO, addMinutes, isValid } from 'date-fns';
+import { addMinutes, isValid, parseISO } from 'date-fns';
 import type { Request, Router } from 'express';
 import express from 'express';
 import { query } from 'express-validator';
 
 import type { IActivity, ISearchFilter } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import Activity from '~/server/models/activity';
 import { configManager } from '~/server/service/config-manager';
@@ -13,18 +13,21 @@ import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
-
 const logger = loggerFactory('growi:routes:apiv3:activity');
 
-
 const validator = {
   list: [
-    query('limit').optional().isInt({ max: 100 }).withMessage('limit must be a number less than or equal to 100'),
+    query('limit')
+      .optional()
+      .isInt({ max: 100 })
+      .withMessage('limit must be a number less than or equal to 100'),
     query('offset').optional().isInt().withMessage('page must be a number'),
-    query('searchFilter').optional().isString().withMessage('query must be a string'),
+    query('searchFilter')
+      .optional()
+      .isString()
+      .withMessage('query must be a string'),
   ],
 };
 
@@ -171,7 +174,9 @@ const validator = {
 
 module.exports = (crowi: Crowi): Router => {
   const adminRequired = require('../../middlewares/admin-required')(crowi);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
 
   const router = express.Router();
 
@@ -209,9 +214,14 @@ module.exports = (crowi: Crowi): Router => {
    *             schema:
    *               $ref: '#/components/schemas/ActivityResponse'
    */
-  router.get('/',
+  router.get(
+    '/',
     accessTokenParser([SCOPE.READ.ADMIN.AUDIT_LOG], { acceptLegacy: true }),
-    loginRequiredStrictly, adminRequired, validator.list, apiV3FormValidator, async(req: Request, res: ApiV3Response) => {
+    loginRequiredStrictly,
+    adminRequired,
+    validator.list,
+    apiV3FormValidator,
+    async (req: Request, res: ApiV3Response) => {
       const auditLogEnabled = configManager.getConfig('app:auditLogEnabled');
       if (!auditLogEnabled) {
         const msg = 'AuditLog is not enabled';
@@ -219,28 +229,36 @@ module.exports = (crowi: Crowi): Router => {
         return res.apiv3Err(msg, 405);
       }
 
-      const limit = req.query.limit || configManager.getConfig('customize:showPageLimitationS');
+      const limit =
+        req.query.limit ||
+        configManager.getConfig('customize:showPageLimitationS');
       const offset = req.query.offset || 1;
 
       const query = {};
 
       try {
-        const parsedSearchFilter = JSON.parse(req.query.searchFilter as string) as ISearchFilter;
+        const parsedSearchFilter = JSON.parse(
+          req.query.searchFilter as string,
+        ) as ISearchFilter;
 
         // add username to query
-        const canContainUsernameFilterToQuery = (
-          parsedSearchFilter.usernames != null
-        && parsedSearchFilter.usernames.length > 0
-        && parsedSearchFilter.usernames.every(u => typeof u === 'string')
-        );
+        const canContainUsernameFilterToQuery =
+          parsedSearchFilter.usernames != null &&
+          parsedSearchFilter.usernames.length > 0 &&
+          parsedSearchFilter.usernames.every((u) => typeof u === 'string');
         if (canContainUsernameFilterToQuery) {
-          Object.assign(query, { 'snapshot.username': parsedSearchFilter.usernames });
+          Object.assign(query, {
+            'snapshot.username': parsedSearchFilter.usernames,
+          });
         }
 
         // add action to query
         if (parsedSearchFilter.actions != null) {
-          const availableActions = crowi.activityService.getAvailableActions(false);
-          const searchableActions = parsedSearchFilter.actions.filter(action => availableActions.includes(action));
+          const availableActions =
+            crowi.activityService.getAvailableActions(false);
+          const searchableActions = parsedSearchFilter.actions.filter(
+            (action) => availableActions.includes(action),
+          );
           Object.assign(query, { action: searchableActions });
         }
 
@@ -255,8 +273,7 @@ module.exports = (crowi: Crowi): Router => {
               $lt: addMinutes(endDate, 1439),
             },
           });
-        }
-        else if (isValid(startDate) && !isValid(endDate)) {
+        } else if (isValid(startDate) && !isValid(endDate)) {
           Object.assign(query, {
             createdAt: {
               $gte: startDate,
@@ -265,23 +282,19 @@ module.exports = (crowi: Crowi): Router => {
             },
           });
         }
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Invalid value', err);
         return res.apiv3Err(err, 400);
       }
 
       try {
-        const paginateResult = await Activity.paginate(
-          query,
-          {
-            lean: true,
-            limit,
-            offset,
-            sort: { createdAt: -1 },
-            populate: 'user',
-          },
-        );
+        const paginateResult = await Activity.paginate(query, {
+          lean: true,
+          limit,
+          offset,
+          sort: { createdAt: -1 },
+          populate: 'user',
+        });
 
         const serializedDocs = paginateResult.docs.map((doc: IActivity) => {
           const { user, ...rest } = doc;
@@ -297,12 +310,12 @@ module.exports = (crowi: Crowi): Router => {
         };
 
         return res.apiv3({ serializedPaginationResult });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Failed to get paginated activity', err);
         return res.apiv3Err(err, 500);
       }
-    });
+    },
+  );
 
   return router;
 };

+ 26 - 15
apps/app/src/server/routes/apiv3/admin-home.ts

@@ -1,4 +1,5 @@
 import { SCOPE } from '@growi/core/dist/interfaces';
+
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { configManager } from '~/server/service/config-manager';
 import { getGrowiVersion } from '~/utils/growi-version';
@@ -60,7 +61,9 @@ const router = express.Router();
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
   const adminRequired = require('../../middlewares/admin-required')(crowi);
 
   /**
@@ -83,22 +86,30 @@ module.exports = (crowi) => {
    *                    adminHomeParams:
    *                      $ref: "#/components/schemas/SystemInformationParams"
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.TOP]), loginRequiredStrictly, adminRequired, async(req, res) => {
-    const { getRuntimeVersions } = await import('~/server/util/runtime-versions');
-    const runtimeVersions = await getRuntimeVersions();
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.TOP]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      const { getRuntimeVersions } = await import(
+        '~/server/util/runtime-versions'
+      );
+      const runtimeVersions = await getRuntimeVersions();
 
-    const adminHomeParams = {
-      growiVersion: getGrowiVersion(),
-      nodeVersion: runtimeVersions.node ?? '-',
-      npmVersion: runtimeVersions.npm ?? '-',
-      pnpmVersion: runtimeVersions.pnpm ?? '-',
-      envVars: configManager.getManagedEnvVars(),
-      isV5Compatible: configManager.getConfig('app:isV5Compatible'),
-      isMaintenanceMode: configManager.getConfig('app:isMaintenanceMode'),
-    };
+      const adminHomeParams = {
+        growiVersion: getGrowiVersion(),
+        nodeVersion: runtimeVersions.node ?? '-',
+        npmVersion: runtimeVersions.npm ?? '-',
+        pnpmVersion: runtimeVersions.pnpm ?? '-',
+        envVars: configManager.getManagedEnvVars(),
+        isV5Compatible: configManager.getConfig('app:isV5Compatible'),
+        isMaintenanceMode: configManager.getConfig('app:isMaintenanceMode'),
+      };
 
-    return res.apiv3({ adminHomeParams });
-  });
+      return res.apiv3({ adminHomeParams });
+    },
+  );
 
   return router;
 };

+ 157 - 94
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -1,9 +1,9 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { body } from 'express-validator';
 import type { Types } from 'mongoose';
 
 import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { InvalidParentBookmarkFolderError } from '~/server/models/errors';
@@ -99,29 +99,44 @@ const router = express.Router();
 const validator = {
   bookmarkFolder: [
     body('name').isString().withMessage('name must be a string'),
-    body('parent').isMongoId().optional({ nullable: true })
-      .custom(async(parent: string) => {
+    body('parent')
+      .isMongoId()
+      .optional({ nullable: true })
+      .custom(async (parent: string) => {
         const parentFolder = await BookmarkFolder.findById(parent);
         if (parentFolder == null || parentFolder.parent != null) {
           throw new Error('Maximum folder hierarchy of 2 levels');
         }
       }),
-    body('childFolder').optional().isArray().withMessage('Children must be an array'),
-    body('bookmarkFolderId').optional().isMongoId().withMessage('Bookark Folder ID must be a valid mongo ID'),
+    body('childFolder')
+      .optional()
+      .isArray()
+      .withMessage('Children must be an array'),
+    body('bookmarkFolderId')
+      .optional()
+      .isMongoId()
+      .withMessage('Bookark Folder ID must be a valid mongo ID'),
   ],
   bookmarkPage: [
     body('pageId').isMongoId().withMessage('Page ID must be a valid mongo ID'),
-    body('folderId').optional({ nullable: true }).isMongoId().withMessage('Folder ID must be a valid mongo ID'),
+    body('folderId')
+      .optional({ nullable: true })
+      .isMongoId()
+      .withMessage('Folder ID must be a valid mongo ID'),
   ],
   bookmark: [
     body('pageId').isMongoId().withMessage('Page ID must be a valid mongo ID'),
-    body('status').isBoolean().withMessage('status must be one of true or false'),
+    body('status')
+      .isBoolean()
+      .withMessage('status must be one of true or false'),
   ],
 };
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
 
   /**
    * @swagger
@@ -157,28 +172,36 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.post('/',
+  router.post(
+    '/',
     accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }),
-    loginRequiredStrictly, validator.bookmarkFolder, apiV3FormValidator, async(req, res) => {
+    loginRequiredStrictly,
+    validator.bookmarkFolder,
+    apiV3FormValidator,
+    async (req, res) => {
       const owner = req.user?._id;
       const { name, parent } = req.body;
       const params = {
-        name, owner, parent,
+        name,
+        owner,
+        parent,
       };
 
       try {
         const bookmarkFolder = await BookmarkFolder.createByParameters(params);
         logger.debug('bookmark folder created', bookmarkFolder);
         return res.apiv3({ bookmarkFolder });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         if (err instanceof InvalidParentBookmarkFolderError) {
-          return res.apiv3Err(new ErrorV3(err.message, 'failed_to_create_bookmark_folder'));
+          return res.apiv3Err(
+            new ErrorV3(err.message, 'failed_to_create_bookmark_folder'),
+          );
         }
         return res.apiv3Err(err, 500);
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -211,63 +234,75 @@ module.exports = (crowi) => {
    *                        type: object
    *                        $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.get('/list/:userId', accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
-    const { userId } = req.params;
+  router.get(
+    '/list/:userId',
+    accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    async (req, res) => {
+      const { userId } = req.params;
 
-    const getBookmarkFolders = async(
+      const getBookmarkFolders = async (
         userId: Types.ObjectId | string,
         parentFolderId?: Types.ObjectId | string,
-    ) => {
-      const folders = await BookmarkFolder.find({ owner: userId, parent: parentFolderId })
-        .populate('childFolder')
-        .populate({
-          path: 'bookmarks',
-          model: 'Bookmark',
-          populate: {
-            path: 'page',
-            model: 'Page',
+      ) => {
+        const folders = (await BookmarkFolder.find({
+          owner: userId,
+          parent: parentFolderId,
+        })
+          .populate('childFolder')
+          .populate({
+            path: 'bookmarks',
+            model: 'Bookmark',
             populate: {
-              path: 'lastUpdateUser',
-              model: 'User',
+              path: 'page',
+              model: 'Page',
+              populate: {
+                path: 'lastUpdateUser',
+                model: 'User',
+              },
             },
-          },
-        }).exec() as never as BookmarkFolderItems[];
+          })
+          .exec()) as never as BookmarkFolderItems[];
 
-      const returnValue: BookmarkFolderItems[] = [];
+        const returnValue: BookmarkFolderItems[] = [];
 
-      const promises = folders.map(async(folder: BookmarkFolderItems) => {
-        const childFolder = await getBookmarkFolders(userId, folder._id);
+        const promises = folders.map(async (folder: BookmarkFolderItems) => {
+          const childFolder = await getBookmarkFolders(userId, folder._id);
 
-        // !! DO NOT THIS SERIALIZING OUTSIDE OF PROMISES !! -- 05.23.2023 ryoji-s
-        // Serializing outside of promises will cause not populated.
-        const bookmarks = folder.bookmarks.map(bookmark => serializeBookmarkSecurely(bookmark));
+          // !! DO NOT THIS SERIALIZING OUTSIDE OF PROMISES !! -- 05.23.2023 ryoji-s
+          // Serializing outside of promises will cause not populated.
+          const bookmarks = folder.bookmarks.map((bookmark) =>
+            serializeBookmarkSecurely(bookmark),
+          );
 
-        const res = {
-          _id: folder._id.toString(),
-          name: folder.name,
-          owner: folder.owner,
-          bookmarks,
-          childFolder,
-          parent: folder.parent,
-        };
-        return res;
-      });
+          const res = {
+            _id: folder._id.toString(),
+            name: folder.name,
+            owner: folder.owner,
+            bookmarks,
+            childFolder,
+            parent: folder.parent,
+          };
+          return res;
+        });
 
-      const results = await Promise.all(promises) as unknown as BookmarkFolderItems[];
-      returnValue.push(...results);
-      return returnValue;
-    };
+        const results = (await Promise.all(
+          promises,
+        )) as unknown as BookmarkFolderItems[];
+        returnValue.push(...results);
+        return returnValue;
+      };
 
-    try {
-      const bookmarkFolderItems = await getBookmarkFolders(userId, undefined);
+      try {
+        const bookmarkFolderItems = await getBookmarkFolders(userId, undefined);
 
-      return res.apiv3({ bookmarkFolderItems });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+        return res.apiv3({ bookmarkFolderItems });
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -299,18 +334,22 @@ module.exports = (crowi) => {
    *                      description: Number of deleted folders
    *                      example: 1
    */
-  router.delete('/:id', accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
-    const { id } = req.params;
-    try {
-      const result = await BookmarkFolder.deleteFolderAndChildren(id);
-      const { deletedCount } = result;
-      return res.apiv3({ deletedCount });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+  router.delete(
+    '/:id',
+    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    async (req, res) => {
+      const { id } = req.params;
+      try {
+        const result = await BookmarkFolder.deleteFolderAndChildren(id);
+        const { deletedCount } = result;
+        return res.apiv3({ deletedCount });
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -355,20 +394,27 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.put('/',
-    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, validator.bookmarkFolder, async(req, res) => {
-      const {
-        bookmarkFolderId, name, parent, childFolder,
-      } = req.body;
+  router.put(
+    '/',
+    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    validator.bookmarkFolder,
+    async (req, res) => {
+      const { bookmarkFolderId, name, parent, childFolder } = req.body;
       try {
-        const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(bookmarkFolderId, name, parent, childFolder);
+        const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(
+          bookmarkFolderId,
+          name,
+          parent,
+          childFolder,
+        );
         return res.apiv3({ bookmarkFolder });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(err, 500);
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -405,22 +451,31 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.post('/add-bookmark-to-folder',
-    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, validator.bookmarkPage, apiV3FormValidator,
-    async(req, res) => {
+  router.post(
+    '/add-bookmark-to-folder',
+    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    validator.bookmarkPage,
+    apiV3FormValidator,
+    async (req, res) => {
       const userId = req.user?._id;
       const { pageId, folderId } = req.body;
 
       try {
-        const bookmarkFolder = await BookmarkFolder.insertOrUpdateBookmarkedPage(pageId, userId, folderId);
+        const bookmarkFolder =
+          await BookmarkFolder.insertOrUpdateBookmarkedPage(
+            pageId,
+            userId,
+            folderId,
+          );
         logger.debug('bookmark added to folder', bookmarkFolder);
         return res.apiv3({ bookmarkFolder });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(err, 500);
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -456,18 +511,26 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.put('/update-bookmark',
-    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, validator.bookmark, async(req, res) => {
+  router.put(
+    '/update-bookmark',
+    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    validator.bookmark,
+    async (req, res) => {
       const { pageId, status } = req.body;
       const userId = req.user?._id;
       try {
-        const bookmarkFolder = await BookmarkFolder.updateBookmark(pageId, status, userId);
+        const bookmarkFolder = await BookmarkFolder.updateBookmark(
+          pageId,
+          status,
+          userId,
+        );
         return res.apiv3({ bookmarkFolder });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(err, 500);
       }
-    });
+    },
+  );
   return router;
 };

+ 315 - 157
apps/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -1,13 +1,13 @@
-import { createReadStream } from 'fs';
-import path from 'path';
-
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { NextFunction, Request, Router } from 'express';
 import express from 'express';
 import { body } from 'express-validator';
+import { createReadStream } from 'fs';
 import multer from 'multer';
+import path from 'path';
 
+import type { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { isG2GTransferError } from '~/server/models/vo/g2g-transfer-error';
 import { configManager } from '~/server/service/config-manager';
@@ -19,15 +19,13 @@ import { getImportService } from '~/server/service/import';
 import loggerFactory from '~/utils/logger';
 import { TransferKey } from '~/utils/vo/transfer-key';
 
-
 import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { Attachment } from '../../models/attachment';
-
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
 interface AuthorizedRequest extends Request {
-  user?: any
+  user?: any;
 }
 
 const logger = loggerFactory('growi:routes:apiv3:transfer');
@@ -76,20 +74,27 @@ const validator = {
  *                 type: string
  *               containerName:
  *                 type: string
-*/
+ */
 /*
  * Routes
  */
 module.exports = (crowi: Crowi): Router => {
   const {
-    g2gTransferPusherService, g2gTransferReceiverService,
+    g2gTransferPusherService,
+    g2gTransferReceiverService,
     growiBridgeService,
   } = crowi;
 
   const importService = getImportService();
 
-  if (g2gTransferPusherService == null || g2gTransferReceiverService == null || exportService == null || importService == null
-    || growiBridgeService == null || configManager == null) {
+  if (
+    g2gTransferPusherService == null ||
+    g2gTransferReceiverService == null ||
+    exportService == null ||
+    importService == null ||
+    growiBridgeService == null ||
+    configManager == null
+  ) {
     throw Error('GROWI is not ready for g2g transfer');
   }
 
@@ -126,10 +131,16 @@ module.exports = (crowi: Crowi): Router => {
   const isInstalled = configManager.getConfig('app:installed');
 
   const adminRequired = require('../../middlewares/admin-required')(crowi);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
 
   // Middleware
-  const adminRequiredIfInstalled = (req: Request, res: ApiV3Response, next: NextFunction) => {
+  const adminRequiredIfInstalled = (
+    req: Request,
+    res: ApiV3Response,
+    next: NextFunction,
+  ) => {
     if (!isInstalled) {
       next();
       return;
@@ -139,29 +150,47 @@ module.exports = (crowi: Crowi): Router => {
   };
 
   // Middleware
-  const appSiteUrlRequiredIfNotInstalled = (req: Request, res: ApiV3Response, next: NextFunction) => {
+  const appSiteUrlRequiredIfNotInstalled = (
+    req: Request,
+    res: ApiV3Response,
+    next: NextFunction,
+  ) => {
     if (!isInstalled && req.body.appSiteUrl != null) {
       next();
       return;
     }
 
-    if (configManager.getConfig('app:siteUrl') != null || req.body.appSiteUrl != null) {
+    if (
+      configManager.getConfig('app:siteUrl') != null ||
+      req.body.appSiteUrl != null
+    ) {
       next();
       return;
     }
 
-    return res.apiv3Err(new ErrorV3('Body param "appSiteUrl" is required when GROWI is NOT installed yet'), 400);
+    return res.apiv3Err(
+      new ErrorV3(
+        'Body param "appSiteUrl" is required when GROWI is NOT installed yet',
+      ),
+      400,
+    );
   };
 
   // Local middleware to check if key is valid or not
-  const validateTransferKey = async(req: Request, res: ApiV3Response, next: NextFunction) => {
+  const validateTransferKey = async (
+    req: Request,
+    res: ApiV3Response,
+    next: NextFunction,
+  ) => {
     const transferKey = req.headers[X_GROWI_TRANSFER_KEY_HEADER_NAME] as string;
 
     try {
       await g2gTransferReceiverService.validateTransferKey(transferKey);
-    }
-    catch (err) {
-      return res.apiv3Err(new ErrorV3('Invalid transfer key', 'invalid_transfer_key'), 403);
+    } catch (err) {
+      return res.apiv3Err(
+        new ErrorV3('Invalid transfer key', 'invalid_transfer_key'),
+        403,
+      );
     }
 
     next();
@@ -200,10 +229,14 @@ module.exports = (crowi: Crowi): Router => {
    *                          type: number
    *                          description: The size of the file
    */
-  receiveRouter.get('/files', validateTransferKey, async(req: Request, res: ApiV3Response) => {
-    const files = await crowi.fileUploadService.listFiles();
-    return res.apiv3({ files });
-  });
+  receiveRouter.get(
+    '/files',
+    validateTransferKey,
+    async (req: Request, res: ApiV3Response) => {
+      const files = await crowi.fileUploadService.listFiles();
+      return res.apiv3({ files });
+    },
+  );
 
   /**
    * @swagger
@@ -251,88 +284,122 @@ module.exports = (crowi: Crowi): Router => {
    *                    type: string
    *                    description: The message of the result
    */
-  receiveRouter.post('/', validateTransferKey, uploads.single('transferDataZipFile'), async(req: Request & { file: any; }, res: ApiV3Response) => {
-    const { file } = req;
-    const {
-      collections: strCollections,
-      optionsMap: strOptionsMap,
-      operatorUserId,
-      uploadConfigs: strUploadConfigs,
-    } = req.body;
-
-    /*
-     * parse multipart form data
-     */
-    let collections;
-    let optionsMap;
-    let sourceGROWIUploadConfigs;
-    try {
-      collections = JSON.parse(strCollections);
-      optionsMap = JSON.parse(strOptionsMap);
-      sourceGROWIUploadConfigs = JSON.parse(strUploadConfigs);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(new ErrorV3('Failed to parse request body.', 'parse_failed'), 500);
-    }
+  receiveRouter.post(
+    '/',
+    validateTransferKey,
+    uploads.single('transferDataZipFile'),
+    async (req: Request & { file: any }, res: ApiV3Response) => {
+      const { file } = req;
+      const {
+        collections: strCollections,
+        optionsMap: strOptionsMap,
+        operatorUserId,
+        uploadConfigs: strUploadConfigs,
+      } = req.body;
+
+      /*
+       * parse multipart form data
+       */
+      let collections: string[];
+      let optionsMap: { [key: string]: GrowiArchiveImportOption };
+      let sourceGROWIUploadConfigs: any;
+      try {
+        collections = JSON.parse(strCollections);
+        optionsMap = JSON.parse(strOptionsMap);
+        sourceGROWIUploadConfigs = JSON.parse(strUploadConfigs);
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3('Failed to parse request body.', 'parse_failed'),
+          500,
+        );
+      }
 
-    /*
-     * unzip and parse
-     */
-    let meta;
-    let innerFileStats;
-    try {
-      const zipFile = importService.getFile(file.filename);
-      await importService.unzip(zipFile);
+      /*
+       * unzip and parse
+       */
+      let meta: object | undefined;
+      let innerFileStats: {
+        fileName: string;
+        collectionName: string;
+        size: number;
+      }[];
+      try {
+        const zipFile = importService.getFile(file.filename);
+        await importService.unzip(zipFile);
 
-      const zipFileStat = await growiBridgeService.parseZipFile(zipFile);
-      innerFileStats = zipFileStat?.innerFileStats;
-      meta = zipFileStat?.meta;
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(new ErrorV3('Failed to validate transfer data file.', 'validation_failed'), 500);
-    }
+        const zipFileStat = await growiBridgeService.parseZipFile(zipFile);
+        innerFileStats = zipFileStat?.innerFileStats ?? [];
+        meta = zipFileStat?.meta;
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3(
+            'Failed to validate transfer data file.',
+            'validation_failed',
+          ),
+          500,
+        );
+      }
 
-    /*
-     * validate meta.json
-     */
-    try {
-      importService.validate(meta);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(
-        new ErrorV3(
-          'The version of this GROWI and the uploaded GROWI data are not the same',
-          'version_incompatible',
-        ),
-        500,
-      );
-    }
+      /*
+       * validate meta.json
+       */
+      try {
+        importService.validate(meta);
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3(
+            'The version of this GROWI and the uploaded GROWI data are not the same',
+            'version_incompatible',
+          ),
+          500,
+        );
+      }
 
-    /*
-     * generate maps of ImportSettings to import
-     */
-    let importSettingsMap: Map<string, ImportSettings>;
-    try {
-      importSettingsMap = g2gTransferReceiverService.getImportSettingMap(innerFileStats, optionsMap, operatorUserId);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(new ErrorV3('Import settings are invalid. See GROWI docs about details.', 'import_settings_invalid'));
-    }
+      /*
+       * generate maps of ImportSettings to import
+       */
+      let importSettingsMap: Map<string, ImportSettings>;
+      try {
+        importSettingsMap = g2gTransferReceiverService.getImportSettingMap(
+          innerFileStats,
+          optionsMap,
+          operatorUserId,
+        );
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3(
+            'Import settings are invalid. See GROWI docs about details.',
+            'import_settings_invalid',
+          ),
+        );
+      }
 
-    try {
-      await g2gTransferReceiverService.importCollections(collections, importSettingsMap, sourceGROWIUploadConfigs);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(new ErrorV3('Failed to import MongoDB collections', 'mongo_collection_import_failure'), 500);
-    }
+      try {
+        await g2gTransferReceiverService.importCollections(
+          collections,
+          importSettingsMap,
+          sourceGROWIUploadConfigs,
+        );
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3(
+            'Failed to import MongoDB collections',
+            'mongo_collection_import_failure',
+          ),
+          500,
+        );
+      }
 
-    return res.apiv3({ message: 'Successfully started to receive transfer data.' });
-  });
+      return res.apiv3({
+        message: 'Successfully started to receive transfer data.',
+      });
+    },
+  );
 
   /**
    * @swagger
@@ -370,54 +437,101 @@ module.exports = (crowi: Crowi): Router => {
    *                    description: The message of the result
    */
   // This endpoint uses multer's MemoryStorage since the received data should be persisted directly on attachment storage.
-  receiveRouter.post('/attachment', validateTransferKey, uploadsForAttachment.single('content'),
-    async(req: Request & { file: any; }, res: ApiV3Response) => {
+  receiveRouter.post(
+    '/attachment',
+    validateTransferKey,
+    uploadsForAttachment.single('content'),
+    async (req: Request & { file: any }, res: ApiV3Response) => {
       const { file } = req;
       const { attachmentMetadata } = req.body;
 
-      let attachmentMap;
+      let attachmentMap: { fileName: any; fileSize: any };
       try {
         attachmentMap = JSON.parse(attachmentMetadata);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('Failed to parse body.', 'parse_failed'), 500);
+        return res.apiv3Err(
+          new ErrorV3('Failed to parse body.', 'parse_failed'),
+          500,
+        );
       }
 
       try {
         const { fileName, fileSize } = attachmentMap;
-        if (typeof fileName !== 'string' || fileName.length === 0 || fileName.length > 256) {
+        if (
+          typeof fileName !== 'string' ||
+          fileName.length === 0 ||
+          fileName.length > 256
+        ) {
           logger.warn('Invalid fileName in attachment metadata.', { fileName });
-          return res.apiv3Err(new ErrorV3('Invalid fileName in attachment metadata.', 'invalid_metadata'), 400);
+          return res.apiv3Err(
+            new ErrorV3(
+              'Invalid fileName in attachment metadata.',
+              'invalid_metadata',
+            ),
+            400,
+          );
         }
-        if (typeof fileSize !== 'number' || !Number.isInteger(fileSize) || fileSize < 0) {
+        if (
+          typeof fileSize !== 'number' ||
+          !Number.isInteger(fileSize) ||
+          fileSize < 0
+        ) {
           logger.warn('Invalid fileSize in attachment metadata.', { fileSize });
-          return res.apiv3Err(new ErrorV3('Invalid fileSize in attachment metadata.', 'invalid_metadata'), 400);
+          return res.apiv3Err(
+            new ErrorV3(
+              'Invalid fileSize in attachment metadata.',
+              'invalid_metadata',
+            ),
+            400,
+          );
         }
         const count = await Attachment.countDocuments({ fileName, fileSize });
         if (count === 0) {
-          logger.warn('Attachment not found in collection.', { fileName, fileSize });
-          return res.apiv3Err(new ErrorV3('Attachment not found in collection.', 'attachment_not_found'), 404);
+          logger.warn('Attachment not found in collection.', {
+            fileName,
+            fileSize,
+          });
+          return res.apiv3Err(
+            new ErrorV3(
+              'Attachment not found in collection.',
+              'attachment_not_found',
+            ),
+            404,
+          );
         }
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('Failed to check attachment existence.', 'attachment_check_failed'), 500);
+        return res.apiv3Err(
+          new ErrorV3(
+            'Failed to check attachment existence.',
+            'attachment_check_failed',
+          ),
+          500,
+        );
       }
 
       const fileStream = createReadStream(file.path, {
-        flags: 'r', mode: 0o666, autoClose: true,
+        flags: 'r',
+        mode: 0o666,
+        autoClose: true,
       });
       try {
-        await g2gTransferReceiverService.receiveAttachment(fileStream, attachmentMap);
-      }
-      catch (err) {
+        await g2gTransferReceiverService.receiveAttachment(
+          fileStream,
+          attachmentMap,
+        );
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('Failed to upload.', 'upload_failed'), 500);
+        return res.apiv3Err(
+          new ErrorV3('Failed to upload.', 'upload_failed'),
+          500,
+        );
       }
 
       return res.apiv3({ message: 'Successfully imported attached file.' });
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -439,23 +553,32 @@ module.exports = (crowi: Crowi): Router => {
    *                  growiInfo:
    *                    $ref: '#/components/schemas/GrowiInfo'
    */
-  receiveRouter.get('/growi-info', validateTransferKey, async(req: Request, res: ApiV3Response) => {
-    let growiInfo: IDataGROWIInfo;
-    try {
-      growiInfo = await g2gTransferReceiverService.answerGROWIInfo();
-    }
-    catch (err) {
-      logger.error(err);
+  receiveRouter.get(
+    '/growi-info',
+    validateTransferKey,
+    async (req: Request, res: ApiV3Response) => {
+      let growiInfo: IDataGROWIInfo;
+      try {
+        growiInfo = await g2gTransferReceiverService.answerGROWIInfo();
+      } catch (err) {
+        logger.error(err);
 
-      if (!isG2GTransferError(err)) {
-        return res.apiv3Err(new ErrorV3('Failed to prepare GROWI info', 'failed_to_prepare_growi_info'), 500);
-      }
+        if (!isG2GTransferError(err)) {
+          return res.apiv3Err(
+            new ErrorV3(
+              'Failed to prepare GROWI info',
+              'failed_to_prepare_growi_info',
+            ),
+            500,
+          );
+        }
 
-      return res.apiv3Err(new ErrorV3(err.message, err.code), 500);
-    }
+        return res.apiv3Err(new ErrorV3(err.message, err.code), 500);
+      }
 
-    return res.apiv3({ growiInfo });
-  });
+      return res.apiv3({ growiInfo });
+    },
+  );
 
   /**
    * @swagger
@@ -489,32 +612,46 @@ module.exports = (crowi: Crowi): Router => {
    *                    type: string
    *                    description: The transfer key
    */
-  receiveRouter.post('/generate-key',
+  receiveRouter.post(
+    '/generate-key',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }),
-    adminRequiredIfInstalled, appSiteUrlRequiredIfNotInstalled, async(req: Request, res: ApiV3Response) => {
-      const appSiteUrl = req.body.appSiteUrl ?? configManager.getConfig('app:siteUrl');
+    adminRequiredIfInstalled,
+    appSiteUrlRequiredIfNotInstalled,
+    async (req: Request, res: ApiV3Response) => {
+      const appSiteUrl =
+        req.body.appSiteUrl ?? configManager.getConfig('app:siteUrl');
 
       let appSiteUrlOrigin: string;
       try {
         appSiteUrlOrigin = new URL(appSiteUrl).origin;
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('appSiteUrl may be wrong', 'failed_to_generate_key_string'));
+        return res.apiv3Err(
+          new ErrorV3(
+            'appSiteUrl may be wrong',
+            'failed_to_generate_key_string',
+          ),
+        );
       }
 
       // Save TransferKey document
       let transferKeyString: string;
       try {
-        transferKeyString = await g2gTransferReceiverService.createTransferKey(appSiteUrlOrigin);
-      }
-      catch (err) {
+        transferKeyString =
+          await g2gTransferReceiverService.createTransferKey(appSiteUrlOrigin);
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('Error occurred while generating transfer key.', 'failed_to_generate_key'));
+        return res.apiv3Err(
+          new ErrorV3(
+            'Error occurred while generating transfer key.',
+            'failed_to_generate_key',
+          ),
+        );
       }
 
       return res.apiv3({ transferKey: transferKeyString });
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -556,44 +693,65 @@ module.exports = (crowi: Crowi): Router => {
    *                    type: string
    *                    description: The message of the result
    */
-  pushRouter.post('/transfer',
+  pushRouter.post(
+    '/transfer',
     accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }),
-    loginRequiredStrictly, adminRequired, validator.transfer, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    loginRequiredStrictly,
+    adminRequired,
+    validator.transfer,
+    apiV3FormValidator,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       const { transferKey, collections, optionsMap } = req.body;
 
       // Parse transfer key
       let tk: TransferKey;
       try {
         tk = TransferKey.parse(transferKey);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('Transfer key is invalid', 'transfer_key_invalid'), 400);
+        return res.apiv3Err(
+          new ErrorV3('Transfer key is invalid', 'transfer_key_invalid'),
+          400,
+        );
       }
 
       // get growi info
       let destGROWIInfo: IDataGROWIInfo;
       try {
         destGROWIInfo = await g2gTransferPusherService.askGROWIInfo(tk);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('Error occurred while asking GROWI info.', 'failed_to_ask_growi_info'));
+        return res.apiv3Err(
+          new ErrorV3(
+            'Error occurred while asking GROWI info.',
+            'failed_to_ask_growi_info',
+          ),
+        );
       }
 
       // Check if can transfer
-      const transferability = await g2gTransferPusherService.getTransferability(destGROWIInfo);
+      const transferability =
+        await g2gTransferPusherService.getTransferability(destGROWIInfo);
       if (!transferability.canTransfer) {
-        return res.apiv3Err(new ErrorV3(transferability.reason, 'growi_incompatible_to_transfer'));
+        return res.apiv3Err(
+          new ErrorV3(transferability.reason, 'growi_incompatible_to_transfer'),
+        );
       }
 
       // Start transfer
       // DO NOT "await". Let it run in the background.
       // Errors should be emitted through websocket.
-      g2gTransferPusherService.startTransfer(tk, req.user, collections, optionsMap, destGROWIInfo);
+      g2gTransferPusherService.startTransfer(
+        tk,
+        req.user,
+        collections,
+        optionsMap,
+        destGROWIInfo,
+      );
 
       return res.apiv3({ message: 'Successfully requested auto transfer.' });
-    });
+    },
+  );
 
   // Merge receiveRouter and pushRouter
   router.use(receiveRouter, pushRouter);

+ 26 - 13
apps/app/src/server/routes/apiv3/healthcheck.ts

@@ -5,10 +5,8 @@ import nocache from 'nocache';
 import loggerFactory from '~/utils/logger';
 
 import { Config } from '../../models/config';
-
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
-
 const logger = loggerFactory('growi:routes:apiv3:healthcheck');
 
 const router = express.Router();
@@ -79,15 +77,19 @@ const router = express.Router();
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-
   async function checkMongo(errors, info) {
     try {
       await Config.findOne({});
 
       info.mongo = 'OK';
-    }
-    catch (err) {
-      errors.push(new ErrorV3(`MongoDB is not connectable - ${err.message}`, 'healthcheck-mongodb-unhealthy', err.stack));
+    } catch (err) {
+      errors.push(
+        new ErrorV3(
+          `MongoDB is not connectable - ${err.message}`,
+          'healthcheck-mongodb-unhealthy',
+          err.stack,
+        ),
+      );
     }
   }
 
@@ -97,9 +99,14 @@ module.exports = (crowi) => {
       try {
         info.searchInfo = await searchService.getInfoForHealth();
         searchService.resetErrorStatus();
-      }
-      catch (err) {
-        errors.push(new ErrorV3(`The Search Service is not connectable - ${err.message}`, 'healthcheck-search-unhealthy', err.stack));
+      } catch (err) {
+        errors.push(
+          new ErrorV3(
+            `The Search Service is not connectable - ${err.message}`,
+            'healthcheck-search-unhealthy',
+            err.stack,
+          ),
+        );
       }
     }
   }
@@ -165,20 +172,26 @@ module.exports = (crowi) => {
    *                  info:
    *                    $ref: '#/components/schemas/HealthcheckInfo'
    */
-  router.get('/', nocache(), async(req, res: ApiV3Response) => {
+  router.get('/', nocache(), async (req, res: ApiV3Response) => {
     let checkServices = (() => {
       if (req.query.checkServices == null) return [];
-      return Array.isArray(req.query.checkServices) ? req.query.checkServices : [req.query.checkServices];
+      return Array.isArray(req.query.checkServices)
+        ? req.query.checkServices
+        : [req.query.checkServices];
     })();
     let isStrictly = req.query.strictly != null;
 
     // for backward compatibility
     if (req.query.connectToMiddlewares != null) {
-      logger.warn('The param \'connectToMiddlewares\' is deprecated. Use \'checkServices[]\' instead.');
+      logger.warn(
+        "The param 'connectToMiddlewares' is deprecated. Use 'checkServices[]' instead.",
+      );
       checkServices = ['mongo', 'search'];
     }
     if (req.query.checkMiddlewaresStrictly != null) {
-      logger.warn('The param \'checkMiddlewaresStrictly\' is deprecated. Use \'checkServices[]\' and \'strictly\' instead.');
+      logger.warn(
+        "The param 'checkMiddlewaresStrictly' is deprecated. Use 'checkServices[]' and 'strictly' instead.",
+      );
       checkServices = ['mongo', 'search'];
       isStrictly = true;
     }

+ 199 - 141
apps/app/src/server/routes/apiv3/import.ts

@@ -1,5 +1,6 @@
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
+import type { Router } from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
 import type { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
@@ -8,11 +9,11 @@ import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { ImportSettings } from '~/server/service/import';
 import { getImportService } from '~/server/service/import';
 import { generateOverwriteParams } from '~/server/service/import/overwrite-params';
+import type { ZipFileStat } from '~/server/service/interfaces/export';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 
-
 const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
 
 const path = require('path');
@@ -20,7 +21,6 @@ const path = require('path');
 const express = require('express');
 const multer = require('multer');
 
-
 const router = express.Router();
 
 /**
@@ -126,7 +126,7 @@ const router = express.Router();
  *                  type: integer
  *                  nullable: true
  */
-export default function route(crowi: Crowi): void {
+export default function route(crowi: Crowi): Router {
   const { growiBridgeService, socketIoService } = crowi;
   const importService = getImportService();
 
@@ -201,22 +201,35 @@ export default function route(crowi: Crowi): void {
    *                        type: string
    *                        description: the access token of qiita.com
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.IMPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
-    try {
-      const importSettingsParams = {
-        esaTeamName: await crowi.configManager.getConfig('importer:esa:team_name'),
-        esaAccessToken: await crowi.configManager.getConfig('importer:esa:access_token'),
-        qiitaTeamName: await crowi.configManager.getConfig('importer:qiita:team_name'),
-        qiitaAccessToken: await crowi.configManager.getConfig('importer:qiita:access_token'),
-      };
-      return res.apiv3({
-        importSettingsParams,
-      });
-    }
-    catch (err) {
-      return res.apiv3Err(err, 500);
-    }
-  });
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.IMPORT_DATA], { acceptLegacy: true }),
+    loginRequired,
+    adminRequired,
+    async (req, res) => {
+      try {
+        const importSettingsParams = {
+          esaTeamName: await crowi.configManager.getConfig(
+            'importer:esa:team_name',
+          ),
+          esaAccessToken: await crowi.configManager.getConfig(
+            'importer:esa:access_token',
+          ),
+          qiitaTeamName: await crowi.configManager.getConfig(
+            'importer:qiita:team_name',
+          ),
+          qiitaAccessToken: await crowi.configManager.getConfig(
+            'importer:qiita:access_token',
+          ),
+        };
+        return res.apiv3({
+          importSettingsParams,
+        });
+      } catch (err) {
+        return res.apiv3Err(err, 500);
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -239,15 +252,20 @@ export default function route(crowi: Crowi): void {
    *                  status:
    *                    $ref: '#/components/schemas/ImportStatus'
    */
-  router.get('/status', accessTokenParser([SCOPE.READ.ADMIN.IMPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
-    try {
-      const status = await importService.getStatus();
-      return res.apiv3(status);
-    }
-    catch (err) {
-      return res.apiv3Err(err, 500);
-    }
-  });
+  router.get(
+    '/status',
+    accessTokenParser([SCOPE.READ.ADMIN.IMPORT_DATA], { acceptLegacy: true }),
+    loginRequired,
+    adminRequired,
+    async (req, res) => {
+      try {
+        const status = await importService.getStatus();
+        return res.apiv3(status);
+      } catch (err) {
+        return res.apiv3Err(err, 500);
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -286,103 +304,131 @@ export default function route(crowi: Crowi): void {
    *        200:
    *          description: Import process has requested
    */
-  router.post('/', accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, addActivity, async(req, res) => {
-    // TODO: add express validator
-    const { fileName, collections, options } = req.body;
-
-    // pages collection can only be imported by upsert if isV5Compatible is true
-    const isV5Compatible = crowi.configManager.getConfig('app:isV5Compatible');
-    const isImportPagesCollection = collections.includes('pages');
-    if (isV5Compatible && isImportPagesCollection) {
-      /** @type {ImportOptionForPages} */
-      const option = options.find(opt => opt.collectionName === 'pages');
-      if (option.mode !== 'upsert') {
-        return res.apiv3Err(new ErrorV3('Upsert is only available for importing pages collection.', 'only_upsert_available'));
+  router.post(
+    '/',
+    accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }),
+    loginRequired,
+    adminRequired,
+    addActivity,
+    async (req, res) => {
+      // TODO: add express validator
+      const { fileName, collections, options } = req.body;
+
+      // pages collection can only be imported by upsert if isV5Compatible is true
+      const isV5Compatible =
+        crowi.configManager.getConfig('app:isV5Compatible');
+      const isImportPagesCollection = collections.includes('pages');
+      if (isV5Compatible && isImportPagesCollection) {
+        /** @type {ImportOptionForPages} */
+        const option = options.find((opt) => opt.collectionName === 'pages');
+        if (option.mode !== 'upsert') {
+          return res.apiv3Err(
+            new ErrorV3(
+              'Upsert is only available for importing pages collection.',
+              'only_upsert_available',
+            ),
+          );
+        }
       }
-    }
-
-    const isMaintenanceMode = crowi.appService.isMaintenanceMode();
-    if (!isMaintenanceMode) {
-      return res.apiv3Err(new ErrorV3('GROWI is not maintenance mode. To import data, please activate the maintenance mode first.', 'not_maintenance_mode'));
-    }
 
+      const isMaintenanceMode = crowi.appService.isMaintenanceMode();
+      if (!isMaintenanceMode) {
+        return res.apiv3Err(
+          new ErrorV3(
+            'GROWI is not maintenance mode. To import data, please activate the maintenance mode first.',
+            'not_maintenance_mode',
+          ),
+        );
+      }
 
-    const zipFile = importService.getFile(fileName);
-
-    // return response first
-    res.apiv3();
+      const zipFile = importService.getFile(fileName);
 
-    /*
-     * unzip, parse
-     */
-    let meta;
-    let fileStatsToImport;
-    try {
-      // unzip
-      await importService.unzip(zipFile);
+      // return response first
+      res.apiv3();
 
-      // eslint-disable-next-line no-unused-vars
-      const parseZipResult = await growiBridgeService.parseZipFile(zipFile);
-      if (parseZipResult == null) {
-        throw new Error('parseZipFile returns null');
+      /*
+       * unzip, parse
+       */
+      let meta: object;
+      let fileStatsToImport: {
+        fileName: string;
+        collectionName: string;
+        size: number;
+      }[];
+      try {
+        // unzip
+        await importService.unzip(zipFile);
+
+        // eslint-disable-next-line no-unused-vars
+        const parseZipResult = await growiBridgeService.parseZipFile(zipFile);
+        if (parseZipResult == null) {
+          throw new Error('parseZipFile returns null');
+        }
+
+        meta = parseZipResult.meta;
+
+        // filter innerFileStats
+        fileStatsToImport = parseZipResult.innerFileStats.filter(
+          ({ collectionName }) => {
+            return collections.includes(collectionName);
+          },
+        );
+      } catch (err) {
+        logger.error(err);
+        adminEvent.emit('onErrorForImport', { message: err.message });
+        return;
       }
 
-      meta = parseZipResult.meta;
+      /*
+       * validate with meta.json
+       */
+      try {
+        importService.validate(meta);
+      } catch (err) {
+        logger.error(err);
+        adminEvent.emit('onErrorForImport', { message: err.message });
+        return;
+      }
 
-      // filter innerFileStats
-      fileStatsToImport = parseZipResult.innerFileStats.filter(({ collectionName }) => {
-        return collections.includes(collectionName);
+      // generate maps of ImportSettings to import
+      // Use the Map for a potential fix for the code scanning alert no. 895: Prototype-polluting assignment
+      const importSettingsMap = new Map<string, ImportSettings>();
+      fileStatsToImport.forEach(({ fileName, collectionName }) => {
+        // instanciate GrowiArchiveImportOption
+        const option: GrowiArchiveImportOption = options.find(
+          (opt) => opt.collectionName === collectionName,
+        );
+
+        // generate options
+        const importSettings = {
+          mode: option.mode,
+          jsonFileName: fileName,
+          overwriteParams: generateOverwriteParams(
+            collectionName,
+            req.user._id,
+            option,
+          ),
+        } satisfies ImportSettings;
+
+        importSettingsMap.set(collectionName, importSettings);
       });
-    }
-    catch (err) {
-      logger.error(err);
-      adminEvent.emit('onErrorForImport', { message: err.message });
-      return;
-    }
-
-    /*
-     * validate with meta.json
-     */
-    try {
-      importService.validate(meta);
-    }
-    catch (err) {
-      logger.error(err);
-      adminEvent.emit('onErrorForImport', { message: err.message });
-      return;
-    }
-
-    // generate maps of ImportSettings to import
-    // Use the Map for a potential fix for the code scanning alert no. 895: Prototype-polluting assignment
-    const importSettingsMap = new Map<string, ImportSettings>();
-    fileStatsToImport.forEach(({ fileName, collectionName }) => {
-      // instanciate GrowiArchiveImportOption
-      const option: GrowiArchiveImportOption = options.find(opt => opt.collectionName === collectionName);
-
-      // generate options
-      const importSettings = {
-        mode: option.mode,
-        jsonFileName: fileName,
-        overwriteParams: generateOverwriteParams(collectionName, req.user._id, option),
-      } satisfies ImportSettings;
-
-      importSettingsMap.set(collectionName, importSettings);
-    });
-
-    /*
-     * import
-     */
-    try {
-      importService.import(collections, importSettingsMap);
-
-      const parameters = { action: SupportedAction.ACTION_ADMIN_GROWI_DATA_IMPORTED };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-    }
-    catch (err) {
-      logger.error(err);
-      adminEvent.emit('onErrorForImport', { message: err.message });
-    }
-  });
+
+      /*
+       * import
+       */
+      try {
+        importService.import(collections, importSettingsMap);
+
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_GROWI_DATA_IMPORTED,
+        };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+      } catch (err) {
+        logger.error(err);
+        adminEvent.emit('onErrorForImport', { message: err.message });
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -412,36 +458,43 @@ export default function route(crowi: Crowi): void {
    *              schema:
    *                $ref: '#/components/schemas/FileImportResponse'
    */
-  router.post('/upload',
-    accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, uploads.single('file'), addActivity,
-    async(req, res) => {
+  router.post(
+    '/upload',
+    accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }),
+    loginRequired,
+    adminRequired,
+    uploads.single('file'),
+    addActivity,
+    async (req, res) => {
       const { file } = req;
       const zipFile = importService.getFile(file.filename);
-      let data;
+      let data: ZipFileStat | null;
 
       try {
         data = await growiBridgeService.parseZipFile(zipFile);
-      }
-      catch (err) {
-      // TODO: use ApiV3Error
+      } catch (err) {
+        // TODO: use ApiV3Error
         logger.error(err);
         return res.status(500).send({ status: 'ERROR' });
       }
       try {
-      // validate with meta.json
-        importService.validate(data.meta);
+        // validate with meta.json
+        importService.validate(data?.meta);
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_UPLOAD };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_UPLOAD,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3(data);
-      }
-      catch {
-        const msg = 'The version of this GROWI and the uploaded GROWI data are not the same';
+      } catch {
+        const msg =
+          'The version of this GROWI and the uploaded GROWI data are not the same';
         const validationErr = 'versions-are-not-met';
         return res.apiv3Err(new ErrorV3(msg, validationErr), 500);
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -458,17 +511,22 @@ export default function route(crowi: Crowi): void {
    *        200:
    *          description: all files are deleted
    */
-  router.delete('/all', accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
-    try {
-      importService.deleteAllZipFiles();
-
-      return res.apiv3();
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+  router.delete(
+    '/all',
+    accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }),
+    loginRequired,
+    adminRequired,
+    async (req, res) => {
+      try {
+        importService.deleteAllZipFiles();
+
+        return res.apiv3();
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+    },
+  );
 
   return router;
 }

+ 88 - 59
apps/app/src/server/routes/apiv3/in-app-notification.ts

@@ -1,17 +1,15 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import express from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 
 import type { IInAppNotification } from '../../../interfaces/in-app-notification';
-
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
-
 const router = express.Router();
 
 /**
@@ -88,7 +86,9 @@ const router = express.Router();
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
   const addActivity = generateAddActivityMiddleware();
 
   const inAppNotificationService = crowi.inAppNotificationService;
@@ -97,7 +97,6 @@ module.exports = (crowi) => {
 
   const activityEvent = crowi.event('activity');
 
-
   /**
    * @swagger
    *
@@ -133,15 +132,21 @@ module.exports = (crowi) => {
    *              schema:
    *                $ref: '#/components/schemas/InAppNotificationListResponse'
    */
-  router.get('/list', accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], { acceptLegacy: true }), loginRequiredStrictly,
-    async(req: CrowiRequest, res: ApiV3Response) => {
-    // user must be set by loginRequiredStrictly
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  router.get(
+    '/list',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req: CrowiRequest, res: ApiV3Response) => {
+      // user must be set by loginRequiredStrictly
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       const user = req.user!;
 
-      const limit = req.query.limit != null
-        ? parseInt(req.query.limit.toString()) || 10
-        : 10;
+      const limit =
+        req.query.limit != null
+          ? parseInt(req.query.limit.toString()) || 10
+          : 10;
 
       let offset = 0;
       if (req.query.offset != null) {
@@ -158,37 +163,42 @@ module.exports = (crowi) => {
         Object.assign(queryOptions, { status: req.query.status });
       }
 
-      const paginationResult = await inAppNotificationService.getLatestNotificationsByUser(user._id, queryOptions);
-
-
-      const getActionUsersFromActivities = function(activities) {
-        return activities.map(({ user }) => user).filter((user, i, self) => self.indexOf(user) === i);
-      };
-
-      const serializedDocs: Array<IInAppNotification> = paginationResult.docs.map((doc) => {
-        if (doc.user != null && doc.user instanceof User) {
-          doc.user = serializeUserSecurely(doc.user);
-        }
-        // To add a new property into mongoose doc, need to change the format of doc to an object
-        const docObj: IInAppNotification = doc.toObject();
-        const actionUsersNew = getActionUsersFromActivities(doc.activities);
-
-        const serializedActionUsers = actionUsersNew.map((actionUser) => {
-          return serializeUserSecurely(actionUser);
+      const paginationResult =
+        await inAppNotificationService.getLatestNotificationsByUser(
+          user._id,
+          queryOptions,
+        );
+
+      const getActionUsersFromActivities = (activities) =>
+        activities
+          .map(({ user }) => user)
+          .filter((user, i, self) => self.indexOf(user) === i);
+
+      const serializedDocs: Array<IInAppNotification> =
+        paginationResult.docs.map((doc) => {
+          if (doc.user != null && doc.user instanceof User) {
+            doc.user = serializeUserSecurely(doc.user);
+          }
+          // To add a new property into mongoose doc, need to change the format of doc to an object
+          const docObj: IInAppNotification = doc.toObject();
+          const actionUsersNew = getActionUsersFromActivities(doc.activities);
+
+          const serializedActionUsers = actionUsersNew.map((actionUser) => {
+            return serializeUserSecurely(actionUser);
+          });
+
+          docObj.actionUsers = serializedActionUsers;
+          return docObj;
         });
 
-        docObj.actionUsers = serializedActionUsers;
-        return docObj;
-      });
-
       const serializedPaginationResult = {
         ...paginationResult,
         docs: serializedDocs,
       };
 
       return res.apiv3(serializedPaginationResult);
-    });
-
+    },
+  );
 
   /**
    * @swagger
@@ -212,20 +222,27 @@ module.exports = (crowi) => {
    *                    type: integer
    *                    description: Count of unread notifications
    */
-  router.get('/status', accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], { acceptLegacy: true }), loginRequiredStrictly,
-    async(req: CrowiRequest, res: ApiV3Response) => {
-    // user must be set by loginRequiredStrictly
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  router.get(
+    '/status',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req: CrowiRequest, res: ApiV3Response) => {
+      // user must be set by loginRequiredStrictly
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       const user = req.user!;
 
       try {
-        const count = await inAppNotificationService.getUnreadCountByUser(user._id);
+        const count = await inAppNotificationService.getUnreadCountByUser(
+          user._id,
+        );
         return res.apiv3({ count });
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(err);
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -256,10 +273,15 @@ module.exports = (crowi) => {
    *              schema:
    *                type: object
    */
-  router.post('/open', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION], { acceptLegacy: true }), loginRequiredStrictly,
-    async(req: CrowiRequest, res: ApiV3Response) => {
-    // user must be set by loginRequiredStrictly
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  router.post(
+    '/open',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req: CrowiRequest, res: ApiV3Response) => {
+      // user must be set by loginRequiredStrictly
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       const user = req.user!;
 
       const id = req.body.id;
@@ -268,11 +290,11 @@ module.exports = (crowi) => {
         const notification = await inAppNotificationService.open(user, id);
         const result = { notification };
         return res.apiv3(result);
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(err);
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -289,24 +311,31 @@ module.exports = (crowi) => {
    *        200:
    *          description: All notifications opened successfully
    */
-  router.put('/all-statuses-open',
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION], { acceptLegacy: true }), loginRequiredStrictly, addActivity,
-    async(req: CrowiRequest, res: ApiV3Response) => {
-    // user must be set by loginRequiredStrictly
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  router.put(
+    '/all-statuses-open',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    addActivity,
+    async (req: CrowiRequest, res: ApiV3Response) => {
+      // user must be set by loginRequiredStrictly
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       const user = req.user!;
 
       try {
         await inAppNotificationService.updateAllNotificationsAsOpened(user);
 
-        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN });
+        activityEvent.emit('update', res.locals.activity._id, {
+          action: SupportedAction.ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN,
+        });
 
         return res.apiv3();
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(err);
       }
-    });
+    },
+  );
 
   return router;
 };

+ 68 - 49
apps/app/src/server/routes/apiv3/installer.ts

@@ -1,3 +1,4 @@
+import type { IUser } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, Router } from 'express';
 import express from 'express';
@@ -9,17 +10,20 @@ import loggerFactory from '~/utils/logger';
 import type Crowi from '../../crowi';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import * as applicationNotInstalled from '../../middlewares/application-not-installed';
-import { registerRules, registerValidation } from '../../middlewares/register-form-validator';
-import { InstallerService, FailedToCreateAdminUserError } from '../../service/installer';
-
+import {
+  registerRules,
+  registerValidation,
+} from '../../middlewares/register-form-validator';
+import {
+  FailedToCreateAdminUserError,
+  InstallerService,
+} from '../../service/installer';
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
-
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:installer');
 
-
-type FormRequest = Request & { form: any, logIn: any };
+type FormRequest = Request & { form: any; logIn: any };
 
 module.exports = (crowi: Crowi): Router => {
   const addActivity = generateAddActivityMiddleware();
@@ -78,53 +82,68 @@ module.exports = (crowi: Crowi): Router => {
    *                    example: Installation completed (Logged in as an admin user)
    */
   // eslint-disable-next-line max-len
-  router.post('/', registerRules(minPasswordLength), registerValidation, addActivity, async(req: FormRequest, res: ApiV3Response) => {
-
-    if (!req.form.isValid) {
-      const errors = req.form.errors;
-      return res.apiv3Err(errors, 400);
-    }
-
-    const registerForm = req.body.registerForm || {};
-
-    const name = registerForm.name;
-    const username = registerForm.username;
-    const email = registerForm.email;
-    const password = registerForm.password;
-    const language = registerForm['app:globalLang'] || 'en_US';
-
-    const installerService = new InstallerService(crowi);
-
-    let adminUser;
-    try {
-      adminUser = await installerService.install({
-        name,
-        username,
-        email,
-        password,
-      }, language);
-    }
-    catch (err) {
-      if (err instanceof FailedToCreateAdminUserError) {
-        return res.apiv3Err(new ErrorV3(err.message, 'failed_to_create_admin_user'));
+  router.post(
+    '/',
+    registerRules(minPasswordLength),
+    registerValidation,
+    addActivity,
+    async (req: FormRequest, res: ApiV3Response) => {
+      if (!req.form.isValid) {
+        const errors = req.form.errors;
+        return res.apiv3Err(errors, 400);
       }
-      return res.apiv3Err(new ErrorV3(err, 'failed_to_install'));
-    }
 
-    await crowi.appService.setupAfterInstall();
-
-    const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
-    activityEvent.emit('update', res.locals.activity._id, parameters);
-
-    // login with passport
-    req.logIn(adminUser, (err) => {
-      if (err != null) {
-        return res.apiv3Err(new ErrorV3(err, 'failed_to_login_after_install'));
+      const registerForm = req.body.registerForm || {};
+
+      const name = registerForm.name;
+      const username = registerForm.username;
+      const email = registerForm.email;
+      const password = registerForm.password;
+      const language = registerForm['app:globalLang'] || 'en_US';
+
+      const installerService = new InstallerService(crowi);
+
+      let adminUser: IUser;
+      try {
+        adminUser = await installerService.install(
+          {
+            name,
+            username,
+            email,
+            password,
+          },
+          language,
+        );
+      } catch (err) {
+        if (err instanceof FailedToCreateAdminUserError) {
+          return res.apiv3Err(
+            new ErrorV3(err.message, 'failed_to_create_admin_user'),
+          );
+        }
+        return res.apiv3Err(new ErrorV3(err, 'failed_to_install'));
       }
 
-      return res.apiv3({ message: 'Installation completed (Logged in as an admin user)' });
-    });
-  });
+      await crowi.appService.setupAfterInstall();
+
+      const parameters = {
+        action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS,
+      };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
+      // login with passport
+      req.logIn(adminUser, (err) => {
+        if (err != null) {
+          return res.apiv3Err(
+            new ErrorV3(err, 'failed_to_login_after_install'),
+          );
+        }
+
+        return res.apiv3({
+          message: 'Installation completed (Logged in as an admin user)',
+        });
+      });
+    },
+  );
 
   return router;
 };

+ 2 - 2
apps/app/src/server/routes/apiv3/interfaces/apiv3-response.ts

@@ -1,6 +1,6 @@
 import type { Response } from 'express';
 
 export interface ApiV3Response extends Response {
-  apiv3(obj?: any, status?: number): any
-  apiv3Err(_err: any, status?: number, info?: any): any
+  apiv3(obj?: any, status?: number): any;
+  apiv3Err(_err: any, status?: number, info?: any): any;
 }

+ 48 - 36
apps/app/src/server/routes/apiv3/invited.ts

@@ -6,16 +6,19 @@ import mongoose from 'mongoose';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../../crowi';
-import { invitedRules, invitedValidation } from '../../middlewares/invited-form-validator';
-
+import {
+  invitedRules,
+  invitedValidation,
+} from '../../middlewares/invited-form-validator';
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
 const logger = loggerFactory('growi:routes:login');
 
-type InvitedFormRequest = Request & { form: any, user: any };
+type InvitedFormRequest = Request & { form: any; user: any };
 
 module.exports = (crowi: Crowi): Router => {
-  const applicationInstalled = require('../../middlewares/application-installed')(crowi);
+  const applicationInstalled =
+    require('../../middlewares/application-installed')(crowi);
   const router = express.Router();
 
   /**
@@ -59,44 +62,53 @@ module.exports = (crowi: Crowi): Router => {
    *                    type: string
    *                    description: URL to redirect after successful activation.
    */
-  router.post('/', applicationInstalled, invitedRules(), invitedValidation, async(req: InvitedFormRequest, res: ApiV3Response) => {
-    if (!req.user) {
-      return res.apiv3({ redirectTo: '/login' });
-    }
+  router.post(
+    '/',
+    applicationInstalled,
+    invitedRules(),
+    invitedValidation,
+    async (req: InvitedFormRequest, res: ApiV3Response) => {
+      if (!req.user) {
+        return res.apiv3({ redirectTo: '/login' });
+      }
 
-    if (!req.form.isValid) {
-      return res.apiv3Err(req.form.errors, 400);
-    }
+      if (!req.form.isValid) {
+        return res.apiv3Err(req.form.errors, 400);
+      }
 
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const User = mongoose.model<IUser, any>('User');
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const User = mongoose.model<IUser, any>('User');
 
-    const user = req.user;
-    const invitedForm = req.form.invitedForm || {};
-    const username = invitedForm.username;
-    const name = invitedForm.name;
-    const password = invitedForm.password;
+      const user = req.user;
+      const invitedForm = req.form.invitedForm || {};
+      const username = invitedForm.username;
+      const name = invitedForm.name;
+      const password = invitedForm.password;
 
-    // check user upper limit
-    const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
-    if (isUserCountExceedsUpperLimit) {
-      return res.apiv3Err('message.can_not_activate_maximum_number_of_users', 403);
-    }
+      // check user upper limit
+      const isUserCountExceedsUpperLimit =
+        await User.isUserCountExceedsUpperLimit();
+      if (isUserCountExceedsUpperLimit) {
+        return res.apiv3Err(
+          'message.can_not_activate_maximum_number_of_users',
+          403,
+        );
+      }
 
-    const creatable = await User.isRegisterableUsername(username);
-    if (!creatable) {
-      logger.debug('username', username);
-      return res.apiv3Err('message.unable_to_use_this_user', 403);
-    }
+      const creatable = await User.isRegisterableUsername(username);
+      if (!creatable) {
+        logger.debug('username', username);
+        return res.apiv3Err('message.unable_to_use_this_user', 403);
+      }
 
-    try {
-      await user.activateInvitedUser(username, name, password);
-      return res.apiv3({ redirectTo: '/' });
-    }
-    catch (err) {
-      return res.apiv3Err('message.failed_to_activate', 403);
-    }
-  });
+      try {
+        await user.activateInvitedUser(username, name, password);
+        return res.apiv3({ redirectTo: '/' });
+      } catch (err) {
+        return res.apiv3Err('message.failed_to_activate', 403);
+      }
+    },
+  );
 
   return router;
 };

+ 116 - 63
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -1,12 +1,10 @@
-import type {
-  IPageInfoForListing, IPageInfo, IUserHasId,
-} from '@growi/core';
+import type { IPageInfo, IPageInfoForListing, IUserHasId } from '@growi/core';
 import { getIdForRef, isIPageInfoForEntity } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, Router } from 'express';
 import express from 'express';
-import { query, oneOf } from 'express-validator';
+import { oneOf, query } from 'express-validator';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 
@@ -20,39 +18,37 @@ import loggerFactory from '~/utils/logger';
 import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import type { PageDocument, PageModel } from '../../models/page';
-
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
-
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
 
 /*
  * Types & Interfaces
  */
 interface AuthorizedRequest extends Request {
-  user?: IUserHasId,
+  user?: IUserHasId;
 }
 
 /*
  * Validators
  */
 const validator = {
-  pagePathRequired: [
-    query('path').isString().withMessage('path is required'),
-  ],
-  pageIdOrPathRequired: oneOf([
-    query('id').isMongoId(),
-    query('path').isString(),
-  ], 'id or path is required'),
+  pagePathRequired: [query('path').isString().withMessage('path is required')],
+  pageIdOrPathRequired: oneOf(
+    [query('id').isMongoId(), query('path').isString()],
+    'id or path is required',
+  ),
   pageIdsOrPathRequired: [
     // type check independent of existence check
-    query('pageIds').isArray().optional(),
+    query('pageIds')
+      .isArray()
+      .optional(),
     query('path').isString().optional(),
     // existence check
-    oneOf([
-      query('pageIds').exists(),
-      query('path').exists(),
-    ], 'pageIds or path is required'),
+    oneOf(
+      [query('pageIds').exists(), query('path').exists()],
+      'pageIds or path is required',
+    ),
   ],
   infoParams: [
     query('attachBookmarkCount').isBoolean().optional(),
@@ -64,11 +60,13 @@ const validator = {
  * Routes
  */
 const routerFactory = (crowi: Crowi): Router => {
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const loginRequired = require('../../middlewares/login-required')(
+    crowi,
+    true,
+  );
 
   const router = express.Router();
 
-
   /**
    * @swagger
    *
@@ -91,16 +89,20 @@ const routerFactory = (crowi: Crowi): Router => {
    *                 rootPage:
    *                   $ref: '#/components/schemas/PageForTreeItem'
    */
-  router.get('/root',
-    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get(
+    '/root',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       try {
-        const rootPage: IPageForTreeItem = await pageListingService.findRootByViewer(req.user);
+        const rootPage: IPageForTreeItem =
+          await pageListingService.findRootByViewer(req.user);
         return res.apiv3({ rootPage });
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(new ErrorV3('rootPage not found'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -138,25 +140,39 @@ const routerFactory = (crowi: Crowi): Router => {
   /*
    * In most cases, using id should be prioritized
    */
-  router.get('/children',
+  router.get(
+    '/children',
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
-    loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    loginRequired,
+    validator.pageIdOrPathRequired,
+    apiV3FormValidator,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       const { id, path } = req.query;
 
-      const hideRestrictedByOwner = await configManager.getConfig('security:list-policy:hideRestrictedByOwner');
-      const hideRestrictedByGroup = await configManager.getConfig('security:list-policy:hideRestrictedByGroup');
+      const hideRestrictedByOwner = await configManager.getConfig(
+        'security:list-policy:hideRestrictedByOwner',
+      );
+      const hideRestrictedByGroup = await configManager.getConfig(
+        'security:list-policy:hideRestrictedByGroup',
+      );
 
       try {
-        const pages = await pageListingService.findChildrenByParentPathOrIdAndViewer(
-          (id || path) as string, req.user, !hideRestrictedByOwner, !hideRestrictedByGroup,
-        );
+        const pages =
+          await pageListingService.findChildrenByParentPathOrIdAndViewer(
+            (id || path) as string,
+            req.user,
+            !hideRestrictedByOwner,
+            !hideRestrictedByGroup,
+          );
         return res.apiv3({ children: pages });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Error occurred while finding children.', err);
-        return res.apiv3Err(new ErrorV3('Error occurred while finding children.'));
+        return res.apiv3Err(
+          new ErrorV3('Error occurred while finding children.'),
+        );
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -200,17 +216,26 @@ const routerFactory = (crowi: Crowi): Router => {
    *               additionalProperties:
    *                 $ref: '#/components/schemas/PageInfoAll'
    */
-  router.get('/info',
+  router.get(
+    '/info',
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
-    validator.pageIdsOrPathRequired, validator.infoParams, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    validator.pageIdsOrPathRequired,
+    validator.infoParams,
+    apiV3FormValidator,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       const {
-        pageIds, path, attachBookmarkCount: attachBookmarkCountParam, attachShortBody: attachShortBodyParam,
+        pageIds,
+        path,
+        attachBookmarkCount: attachBookmarkCountParam,
+        attachShortBody: attachShortBodyParam,
       } = req.query;
 
       const attachBookmarkCount: boolean = attachBookmarkCountParam === 'true';
       const attachShortBody: boolean = attachShortBodyParam === 'true';
 
-      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+        'Page',
+      );
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
       const Bookmark = mongoose.model<any, any>('Bookmark');
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -219,32 +244,55 @@ const routerFactory = (crowi: Crowi): Router => {
       const pageGrantService: IPageGrantService = crowi.pageGrantService!;
 
       try {
-        const pages = pageIds != null
-          ? await Page.findByIdsAndViewer(pageIds as string[], req.user, null, true)
-          : await Page.findByPathAndViewer(path as string, req.user, null, false, true);
+        const pages =
+          pageIds != null
+            ? await Page.findByIdsAndViewer(
+                pageIds as string[],
+                req.user,
+                null,
+                true,
+              )
+            : await Page.findByPathAndViewer(
+                path as string,
+                req.user,
+                null,
+                false,
+                true,
+              );
 
-        const foundIds = pages.map(page => page._id);
+        const foundIds = pages.map((page) => page._id);
 
-        let shortBodiesMap;
+        let shortBodiesMap: Record<string, string | null> | undefined;
         if (attachShortBody) {
-          shortBodiesMap = await pageService.shortBodiesMapByPageIds(foundIds, req.user);
+          shortBodiesMap = await pageService.shortBodiesMapByPageIds(
+            foundIds,
+            req.user,
+          );
         }
 
-        let bookmarkCountMap;
+        let bookmarkCountMap: Record<string, number> | undefined;
         if (attachBookmarkCount) {
-          bookmarkCountMap = await Bookmark.getPageIdToCountMap(foundIds) as Record<string, number>;
+          bookmarkCountMap = (await Bookmark.getPageIdToCountMap(
+            foundIds,
+          )) as Record<string, number>;
         }
 
-        const idToPageInfoMap: Record<string, IPageInfo | IPageInfoForListing> = {};
+        const idToPageInfoMap: Record<string, IPageInfo | IPageInfoForListing> =
+          {};
 
         const isGuestUser = req.user == null;
 
-        const userRelatedGroups = await pageGrantService.getUserRelatedGroups(req.user);
+        const userRelatedGroups = await pageGrantService.getUserRelatedGroups(
+          req.user,
+        );
 
         for (const page of pages) {
           const basicPageInfo = {
             ...pageService.constructBasicPageInfo(page, isGuestUser),
-            bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id.toString()] ?? 0 : 0,
+            bookmarkCount:
+              bookmarkCountMap != null
+                ? (bookmarkCountMap[page._id.toString()] ?? 0)
+                : 0,
           };
 
           // TODO: use pageService.getCreatorIdForCanDelete to get creatorId (https://redmine.weseek.co.jp/issues/140574)
@@ -256,24 +304,29 @@ const routerFactory = (crowi: Crowi): Router => {
             userRelatedGroups,
           ); // use normal delete config
 
-          const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
+          const pageInfo = !isIPageInfoForEntity(basicPageInfo)
             ? basicPageInfo
-            : {
-              ...basicPageInfo,
-              isAbleToDeleteCompletely: canDeleteCompletely,
-              revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id.toString()] : undefined,
-            } satisfies IPageInfoForListing;
+            : ({
+                ...basicPageInfo,
+                isAbleToDeleteCompletely: canDeleteCompletely,
+                revisionShortBody:
+                  shortBodiesMap != null
+                    ? (shortBodiesMap[page._id.toString()] ?? undefined)
+                    : undefined,
+              } satisfies IPageInfoForListing);
 
           idToPageInfoMap[page._id.toString()] = pageInfo;
         }
 
         return res.apiv3(idToPageInfoMap);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Error occurred while fetching page informations.', err);
-        return res.apiv3Err(new ErrorV3('Error occurred while fetching page informations.'));
+        return res.apiv3Err(
+          new ErrorV3('Error occurred while fetching page informations.'),
+        );
       }
-    });
+    },
+  );
 
   return router;
 };

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


+ 44 - 32
apps/app/src/server/routes/apiv3/personal-setting/delete-access-token.ts

@@ -1,9 +1,9 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import { query } from 'express-validator';
 
 import { SupportedAction } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
@@ -14,13 +14,20 @@ import loggerFactory from '~/utils/logger';
 
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
-const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-access-tokens');
+const logger = loggerFactory(
+  'growi:routes:apiv3:personal-setting:generate-access-tokens',
+);
 
 type ReqQuery = {
-  tokenId: string,
-}
+  tokenId: string;
+};
 
-type DeleteAccessTokenRequest = Request<undefined, ApiV3Response, undefined, ReqQuery>;
+type DeleteAccessTokenRequest = Request<
+  undefined,
+  ApiV3Response,
+  undefined,
+  ReqQuery
+>;
 
 type DeleteAccessTokenHandlersFactory = (crowi: Crowi) => RequestHandler[];
 
@@ -32,34 +39,39 @@ const validator = [
     .withMessage('tokenId must be a string'),
 ];
 
-export const deleteAccessTokenHandlersFactory: DeleteAccessTokenHandlersFactory = (crowi) => {
+export const deleteAccessTokenHandlersFactory: DeleteAccessTokenHandlersFactory =
+  (crowi) => {
+    const loginRequiredStrictly =
+      require('../../../middlewares/login-required')(crowi);
+    const addActivity = generateAddActivityMiddleware();
+    const activityEvent = crowi.event('activity');
 
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
-  const addActivity = generateAddActivityMiddleware();
-  const activityEvent = crowi.event('activity');
+    return [
+      accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
+      loginRequiredStrictly,
+      excludeReadOnlyUser,
+      addActivity,
+      validator,
+      apiV3FormValidator,
+      async (req: DeleteAccessTokenRequest, res: ApiV3Response) => {
+        const { query } = req;
+        const { tokenId } = query;
 
-  return [
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
-    loginRequiredStrictly,
-    excludeReadOnlyUser,
-    addActivity,
-    validator,
-    apiV3FormValidator,
-    async(req: DeleteAccessTokenRequest, res: ApiV3Response) => {
-      const { query } = req;
-      const { tokenId } = query;
+        try {
+          await AccessToken.deleteTokenById(tokenId);
 
-      try {
-        await AccessToken.deleteTokenById(tokenId);
+          const parameters = {
+            action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE,
+          };
+          activityEvent.emit('update', res.locals.activity._id, parameters);
 
-        const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE };
-        activityEvent.emit('update', res.locals.activity._id, parameters);
-
-        return res.apiv3({});
-      }
-      catch (err) {
-        logger.error(err);
-        return res.apiv3Err(new ErrorV3(err.toString(), 'delete-access-token-failed'));
-      }
-    }];
-};
+          return res.apiv3({});
+        } catch (err) {
+          logger.error(err);
+          return res.apiv3Err(
+            new ErrorV3(err.toString(), 'delete-access-token-failed'),
+          );
+        }
+      },
+    ];
+  };

+ 40 - 32
apps/app/src/server/routes/apiv3/personal-setting/delete-all-access-tokens.ts

@@ -1,9 +1,9 @@
 import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
@@ -13,39 +13,47 @@ import loggerFactory from '~/utils/logger';
 
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
-const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-access-tokens');
+const logger = loggerFactory(
+  'growi:routes:apiv3:personal-setting:generate-access-tokens',
+);
 
-interface DeleteAllAccessTokensRequest extends Request<undefined, ApiV3Response, undefined> {
-  user: IUserHasId,
+interface DeleteAllAccessTokensRequest
+  extends Request<undefined, ApiV3Response, undefined> {
+  user: IUserHasId;
 }
 
 type DeleteAllAccessTokensHandlersFactory = (crowi: Crowi) => RequestHandler[];
 
-export const deleteAllAccessTokensHandlersFactory: DeleteAllAccessTokensHandlersFactory = (crowi) => {
-
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
-  const addActivity = generateAddActivityMiddleware();
-  const activityEvent = crowi.event('activity');
-
-  return [
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
-    loginRequiredStrictly,
-    excludeReadOnlyUser,
-    addActivity,
-    async(req: DeleteAllAccessTokensRequest, res: ApiV3Response) => {
-      const { user } = req;
-
-      try {
-        await AccessToken.deleteAllTokensByUserId(user._id);
-
-        const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE };
-        activityEvent.emit('update', res.locals.activity._id, parameters);
-
-        return res.apiv3({});
-      }
-      catch (err) {
-        logger.error(err);
-        return res.apiv3Err(new ErrorV3(err.toString(), 'delete-all-access-token-failed'));
-      }
-    }];
-};
+export const deleteAllAccessTokensHandlersFactory: DeleteAllAccessTokensHandlersFactory =
+  (crowi) => {
+    const loginRequiredStrictly =
+      require('../../../middlewares/login-required')(crowi);
+    const addActivity = generateAddActivityMiddleware();
+    const activityEvent = crowi.event('activity');
+
+    return [
+      accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
+      loginRequiredStrictly,
+      excludeReadOnlyUser,
+      addActivity,
+      async (req: DeleteAllAccessTokensRequest, res: ApiV3Response) => {
+        const { user } = req;
+
+        try {
+          await AccessToken.deleteAllTokensByUserId(user._id);
+
+          const parameters = {
+            action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE,
+          };
+          activityEvent.emit('update', res.locals.activity._id, parameters);
+
+          return res.apiv3({});
+        } catch (err) {
+          logger.error(err);
+          return res.apiv3Err(
+            new ErrorV3(err.toString(), 'delete-all-access-token-failed'),
+          );
+        }
+      },
+    ];
+  };

+ 51 - 42
apps/app/src/server/routes/apiv3/personal-setting/generate-access-token.ts

@@ -1,6 +1,4 @@
-import type {
-  IUserHasId, Scope,
-} from '@growi/core/dist/interfaces';
+import type { IUserHasId, Scope } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import { body } from 'express-validator';
@@ -16,16 +14,19 @@ import loggerFactory from '~/utils/logger';
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
-const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-access-tokens');
+const logger = loggerFactory(
+  'growi:routes:apiv3:personal-setting:generate-access-tokens',
+);
 
 type ReqBody = {
-  expiredAt: Date,
-  description?: string,
-  scopes?: Scope[],
-}
+  expiredAt: Date;
+  description?: string;
+  scopes?: Scope[];
+};
 
-interface GenerateAccessTokenRequest extends Request<undefined, ApiV3Response, ReqBody> {
-  user: IUserHasId,
+interface GenerateAccessTokenRequest
+  extends Request<undefined, ApiV3Response, ReqBody> {
+  user: IUserHasId;
 }
 
 type GenerateAccessTokenHandlerFactory = (crowi: Crowi) => RequestHandler[];
@@ -73,35 +74,43 @@ const validator = [
     .withMessage('Invalid scope'),
 ];
 
-export const generateAccessTokenHandlerFactory: GenerateAccessTokenHandlerFactory = (crowi) => {
-
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
-  const activityEvent = crowi.event('activity');
-  const addActivity = generateAddActivityMiddleware();
-
-  return [
-    loginRequiredStrictly,
-    excludeReadOnlyUser,
-    addActivity,
-    validator,
-    apiV3FormValidator,
-    async(req: GenerateAccessTokenRequest, res: ApiV3Response) => {
-
-      const { user, body } = req;
-      const { expiredAt, description, scopes } = body;
-
-      try {
-        const tokenData = await AccessToken.generateToken(user._id, expiredAt, scopes, description);
-
-        const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_CREATE };
-        activityEvent.emit('update', res.locals.activity._id, parameters);
-
-        return res.apiv3(tokenData);
-      }
-      catch (err) {
-        logger.error(err);
-        return res.apiv3Err(new ErrorV3(err.toString(), 'generate-access-token-failed'));
-      }
-    },
-  ];
-};
+export const generateAccessTokenHandlerFactory: GenerateAccessTokenHandlerFactory =
+  (crowi) => {
+    const loginRequiredStrictly =
+      require('../../../middlewares/login-required')(crowi);
+    const activityEvent = crowi.event('activity');
+    const addActivity = generateAddActivityMiddleware();
+
+    return [
+      loginRequiredStrictly,
+      excludeReadOnlyUser,
+      addActivity,
+      validator,
+      apiV3FormValidator,
+      async (req: GenerateAccessTokenRequest, res: ApiV3Response) => {
+        const { user, body } = req;
+        const { expiredAt, description, scopes } = body;
+
+        try {
+          const tokenData = await AccessToken.generateToken(
+            user._id,
+            expiredAt,
+            scopes,
+            description,
+          );
+
+          const parameters = {
+            action: SupportedAction.ACTION_USER_ACCESS_TOKEN_CREATE,
+          };
+          activityEvent.emit('update', res.locals.activity._id, parameters);
+
+          return res.apiv3(tokenData);
+        } catch (err) {
+          logger.error(err);
+          return res.apiv3Err(
+            new ErrorV3(err.toString(), 'generate-access-token-failed'),
+          );
+        }
+      },
+    ];
+  };

+ 18 - 11
apps/app/src/server/routes/apiv3/personal-setting/get-access-tokens.ts

@@ -1,8 +1,8 @@
 import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
@@ -12,17 +12,23 @@ import loggerFactory from '~/utils/logger';
 
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
-const logger = loggerFactory('growi:routes:apiv3:personal-setting:get-access-tokens');
+const logger = loggerFactory(
+  'growi:routes:apiv3:personal-setting:get-access-tokens',
+);
 
-interface GetAccessTokenRequest extends Request<undefined, ApiV3Response, undefined> {
-  user: IUserHasId,
+interface GetAccessTokenRequest
+  extends Request<undefined, ApiV3Response, undefined> {
+  user: IUserHasId;
 }
 
 type GetAccessTokenHandlerFactory = (crowi: Crowi) => RequestHandler[];
 
-export const getAccessTokenHandlerFactory: GetAccessTokenHandlerFactory = (crowi) => {
-
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+export const getAccessTokenHandlerFactory: GetAccessTokenHandlerFactory = (
+  crowi,
+) => {
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(
+    crowi,
+  );
   const addActivity = generateAddActivityMiddleware();
 
   return [
@@ -30,16 +36,17 @@ export const getAccessTokenHandlerFactory: GetAccessTokenHandlerFactory = (crowi
     loginRequiredStrictly,
     excludeReadOnlyUser,
     addActivity,
-    async(req: GetAccessTokenRequest, res: ApiV3Response) => {
+    async (req: GetAccessTokenRequest, res: ApiV3Response) => {
       const { user } = req;
 
       try {
         const accessTokens = await AccessToken.findTokenByUserId(user._id);
         return res.apiv3({ accessTokens });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3(err.toString(), 'colud_not_get_access_token'));
+        return res.apiv3Err(
+          new ErrorV3(err.toString(), 'colud_not_get_access_token'),
+        );
       }
     },
   ];

+ 277 - 157
apps/app/src/server/routes/apiv3/personal-setting/index.js

@@ -2,7 +2,6 @@ import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { body } from 'express-validator';
 
-
 import { i18n } from '^/config/next-i18next.config';
 
 import { SupportedAction } from '~/interfaces/activity';
@@ -14,13 +13,11 @@ import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import EditorSettings from '../../../models/editor-settings';
 import ExternalAccount from '../../../models/external-account';
 import InAppNotificationSettings from '../../../models/in-app-notification-settings';
-
 import { deleteAccessTokenHandlersFactory } from './delete-access-token';
 import { deleteAllAccessTokensHandlersFactory } from './delete-all-access-tokens';
 import { generateAccessTokenHandlerFactory } from './generate-access-token';
 import { getAccessTokenHandlerFactory } from './get-access-tokens';
 
-
 const logger = loggerFactory('growi:routes:apiv3:personal-setting');
 
 const express = require('express');
@@ -76,14 +73,18 @@ const router = express.Router();
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(
+    crowi,
+  );
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const { User } = crowi.models;
 
   const activityEvent = crowi.event('activity');
 
-  const minPasswordLength = crowi.configManager.getConfig('app:minPasswordLength');
+  const minPasswordLength = crowi.configManager.getConfig(
+    'app:minPasswordLength',
+  );
 
   const validator = {
     personal: [
@@ -91,24 +92,31 @@ module.exports = (crowi) => {
       body('email')
         .isEmail()
         .custom((email) => {
-          if (!User.isEmailValid(email)) throw new Error('email is not included in whitelist');
+          if (!User.isEmailValid(email))
+            throw new Error('email is not included in whitelist');
           return true;
         }),
       body('lang').isString().isIn(i18n.locales),
       body('isEmailPublished').isBoolean(),
       body('slackMemberId').optional().isString(),
     ],
-    imageType: [
-      body('isGravatarEnabled').isBoolean(),
-    ],
+    imageType: [body('isGravatarEnabled').isBoolean()],
     password: [
       body('oldPassword').isString(),
-      body('newPassword').isString().not().isEmpty()
+      body('newPassword')
+        .isString()
+        .not()
+        .isEmpty()
         .isLength({ min: minPasswordLength })
-        .withMessage(`password must be at least ${minPasswordLength} characters long`),
-      body('newPasswordConfirm').isString().not().isEmpty()
+        .withMessage(
+          `password must be at least ${minPasswordLength} characters long`,
+        ),
+      body('newPasswordConfirm')
+        .isString()
+        .not()
+        .isEmpty()
         .custom((value, { req }) => {
-          return (value === req.body.newPassword);
+          return value === req.body.newPassword;
         }),
     ],
     associateLdap: [
@@ -150,24 +158,28 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: personal params
    */
-  router.get('/', accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
-    const { username } = req.user;
-    try {
-      const user = await User.findUserByUsername(username);
-
-      // return email and apiToken
-      const { email, apiToken } = user;
-      const currentUser = user.toObject();
-      currentUser.email = email;
-      currentUser.apiToken = apiToken;
-
-      return res.apiv3({ currentUser });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('update-personal-settings-failed');
-    }
-  });
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    async (req, res) => {
+      const { username } = req.user;
+      try {
+        const user = await User.findUserByUsername(username);
+
+        // return email and apiToken
+        const { email, apiToken } = user;
+        const currentUser = user.toObject();
+        currentUser.email = email;
+        currentUser.apiToken = apiToken;
+
+        return res.apiv3({ currentUser });
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err('update-personal-settings-failed');
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -191,21 +203,28 @@ module.exports = (crowi) => {
    *                      type: number
    *                      description: Minimum password length
    */
-  router.get('/is-password-set', accessTokenParser([SCOPE.READ.USER_SETTINGS.PASSWORD], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
-    const { username } = req.user;
-
-    try {
-      const user = await User.findUserByUsername(username);
-      const isPasswordSet = user.isPasswordSet();
-      const minPasswordLength = crowi.configManager.getConfig('app:minPasswordLength');
-      return res.apiv3({ isPasswordSet, minPasswordLength });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('fail-to-get-whether-password-is-set');
-    }
-
-  });
+  router.get(
+    '/is-password-set',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.PASSWORD], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req, res) => {
+      const { username } = req.user;
+
+      try {
+        const user = await User.findUserByUsername(username);
+        const isPasswordSet = user.isPasswordSet();
+        const minPasswordLength = crowi.configManager.getConfig(
+          'app:minPasswordLength',
+        );
+        return res.apiv3({ isPasswordSet, minPasswordLength });
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err('fail-to-get-whether-password-is-set');
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -232,10 +251,14 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: personal params
    */
-  router.put('/',
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.INFO], { acceptLegacy: true }), loginRequiredStrictly, addActivity, validator.personal, apiV3FormValidator,
-    async(req, res) => {
-
+  router.put(
+    '/',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.INFO], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    addActivity,
+    validator.personal,
+    apiV3FormValidator,
+    async (req, res) => {
       try {
         const user = await User.findOne({ _id: req.user.id });
         user.name = req.body.name;
@@ -248,22 +271,28 @@ module.exports = (crowi) => {
 
         if (!isUniqueEmail) {
           logger.error('email-is-not-unique');
-          return res.apiv3Err(new ErrorV3('The email is already in use', 'email-is-already-in-use'));
+          return res.apiv3Err(
+            new ErrorV3(
+              'The email is already in use',
+              'email-is-already-in-use',
+            ),
+          );
         }
 
         const updatedUser = await user.save();
 
-        const parameters = { action: SupportedAction.ACTION_USER_PERSONAL_SETTINGS_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_USER_PERSONAL_SETTINGS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ updatedUser });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err('update-personal-settings-failed');
       }
-
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -293,24 +322,32 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: user data
    */
-  router.put('/image-type', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.INFO], { acceptLegacy: true }), loginRequiredStrictly, addActivity,
-    validator.imageType, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/image-type',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.INFO], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    addActivity,
+    validator.imageType,
+    apiV3FormValidator,
+    async (req, res) => {
       const { isGravatarEnabled } = req.body;
 
       try {
-        const userData = await req.user.updateIsGravatarEnabled(isGravatarEnabled);
+        const userData =
+          await req.user.updateIsGravatarEnabled(isGravatarEnabled);
 
-        const parameters = { action: SupportedAction.ACTION_USER_IMAGE_TYPE_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_USER_IMAGE_TYPE_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ userData });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err('update-personal-settings-failed');
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -331,20 +368,24 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: array of external accounts
    */
-  router.get('/external-accounts',
-    accessTokenParser([SCOPE.READ.USER_SETTINGS.EXTERNAL_ACCOUNT], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
+  router.get(
+    '/external-accounts',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.EXTERNAL_ACCOUNT], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req, res) => {
       const userData = req.user;
 
       try {
         const externalAccounts = await ExternalAccount.find({ user: userData });
         return res.apiv3({ externalAccounts });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err('get-external-accounts-failed');
       }
-
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -376,9 +417,16 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: user data updated
    */
-  router.put('/password',
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.PASSWORD], { acceptLegacy: true }), loginRequiredStrictly, addActivity, validator.password, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/password',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.PASSWORD], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    addActivity,
+    validator.password,
+    apiV3FormValidator,
+    async (req, res) => {
       const { body, user } = req;
       const { oldPassword, newPassword } = body;
 
@@ -388,17 +436,18 @@ module.exports = (crowi) => {
       try {
         const userData = await user.updatePassword(newPassword);
 
-        const parameters = { action: SupportedAction.ACTION_USER_PASSWORD_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_USER_PASSWORD_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ userData });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err('update-password-failed');
       }
-
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -421,23 +470,29 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: user data
    */
-  router.put('/api-token', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.API_TOKEN]), loginRequiredStrictly, addActivity, async(req, res) => {
-    const { user } = req;
-
-    try {
-      const userData = await user.updateApiToken();
+  router.put(
+    '/api-token',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.API_TOKEN]),
+    loginRequiredStrictly,
+    addActivity,
+    async (req, res) => {
+      const { user } = req;
 
-      const parameters = { action: SupportedAction.ACTION_USER_API_TOKEN_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+      try {
+        const userData = await user.updateApiToken();
 
-      return res.apiv3({ userData });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('update-api-token-failed');
-    }
+        const parameters = {
+          action: SupportedAction.ACTION_USER_API_TOKEN_UPDATE,
+        };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-  });
+        return res.apiv3({ userData });
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err('update-api-token-failed');
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -458,7 +513,11 @@ module.exports = (crowi) => {
    *                     type: object
    *                     description: array of access tokens
    */
-  router.get('/access-token', accessTokenParser([SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN]), getAccessTokenHandlerFactory(crowi));
+  router.get(
+    '/access-token',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN]),
+    getAccessTokenHandlerFactory(crowi),
+  );
 
   /**
    * @swagger
@@ -493,7 +552,11 @@ module.exports = (crowi) => {
    *                     items:
    *                      type: string
    */
-  router.post('/access-token', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]), generateAccessTokenHandlerFactory(crowi));
+  router.post(
+    '/access-token',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
+    generateAccessTokenHandlerFactory(crowi),
+  );
 
   /**
    * @swagger
@@ -508,7 +571,11 @@ module.exports = (crowi) => {
    *           description: succeded to delete access token
    *
    */
-  router.delete('/access-token', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]), deleteAccessTokenHandlersFactory(crowi));
+  router.delete(
+    '/access-token',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
+    deleteAccessTokenHandlersFactory(crowi),
+  );
 
   /**
    * @swagger
@@ -522,7 +589,11 @@ module.exports = (crowi) => {
    *         200:
    *           description: succeded to delete all access tokens
    */
-  router.delete('/access-token/all', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]), deleteAllAccessTokensHandlersFactory(crowi));
+  router.delete(
+    '/access-token/all',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
+    deleteAllAccessTokensHandlersFactory(crowi),
+  );
 
   /**
    * @swagger
@@ -552,9 +623,14 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: Ldap account associate to me
    */
-  router.put('/associate-ldap', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.EXTERNAL_ACCOUNT]), loginRequiredStrictly, addActivity,
-    validator.associateLdap, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/associate-ldap',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.EXTERNAL_ACCOUNT]),
+    loginRequiredStrictly,
+    addActivity,
+    validator.associateLdap,
+    apiV3FormValidator,
+    async (req, res) => {
       const { passportService } = crowi;
       const { user, body } = req;
       const { username } = body;
@@ -566,19 +642,24 @@ module.exports = (crowi) => {
 
       try {
         await passport.authenticate('ldapauth');
-        const associateUser = await ExternalAccount.associate('ldap', username, user);
+        const associateUser = await ExternalAccount.associate(
+          'ldap',
+          username,
+          user,
+        );
 
-        const parameters = { action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_ASSOCIATE };
+        const parameters = {
+          action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ associateUser });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err('associate-ldap-account-failed');
       }
-
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -605,9 +686,14 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: Ldap account disassociate to me
    */
-  router.put('/disassociate-ldap',
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.EXTERNAL_ACCOUNT]), loginRequiredStrictly, addActivity, validator.disassociateLdap, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/disassociate-ldap',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.EXTERNAL_ACCOUNT]),
+    loginRequiredStrictly,
+    addActivity,
+    validator.disassociateLdap,
+    apiV3FormValidator,
+    async (req, res) => {
       const { user, body } = req;
       const { providerType, accountId } = body;
 
@@ -623,17 +709,18 @@ module.exports = (crowi) => {
           user,
         });
 
-        const parameters = { action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_DISCONNECT };
+        const parameters = {
+          action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_DISCONNECT,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ disassociateUser });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err('disassociate-ldap-account-failed');
       }
-
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -666,37 +753,49 @@ module.exports = (crowi) => {
    *                  type: object
    *                  description: editor settings
    */
-  router.put('/editor-settings', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.OTHER]), loginRequiredStrictly,
-    addActivity, validator.editorSettings, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/editor-settings',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.OTHER]),
+    loginRequiredStrictly,
+    addActivity,
+    validator.editorSettings,
+    apiV3FormValidator,
+    async (req, res) => {
       const query = { userId: req.user.id };
       const { body } = req;
 
-      const {
-        theme, keymapMode, styleActiveLine, autoFormatMarkdownTable,
-      } = body;
+      const { theme, keymapMode, styleActiveLine, autoFormatMarkdownTable } =
+        body;
 
       const document = {
-        theme, keymapMode, styleActiveLine, autoFormatMarkdownTable,
+        theme,
+        keymapMode,
+        styleActiveLine,
+        autoFormatMarkdownTable,
       };
 
       // Insert if document does not exist, and return new values
       // See: https://mongoosejs.com/docs/api.html#model_Model.findOneAndUpdate
       const options = { upsert: true, new: true };
       try {
-        const response = await EditorSettings.findOneAndUpdate(query, { $set: document }, options);
-
-        const parameters = { action: SupportedAction.ACTION_USER_EDITOR_SETTINGS_UPDATE };
+        const response = await EditorSettings.findOneAndUpdate(
+          query,
+          { $set: document },
+          options,
+        );
+
+        const parameters = {
+          action: SupportedAction.ACTION_USER_EDITOR_SETTINGS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3(response);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err('updating-editor-settings-failed');
       }
-    });
-
+    },
+  );
 
   /**
    * @swagger
@@ -715,17 +814,22 @@ module.exports = (crowi) => {
    *                  type: object
    *                  description: editor settings
    */
-  router.get('/editor-settings', accessTokenParser([SCOPE.READ.USER_SETTINGS.OTHER]), loginRequiredStrictly, async(req, res) => {
-    try {
-      const query = { userId: req.user.id };
-      const editorSettings = await EditorSettings.findOne(query) ?? new EditorSettings();
-      return res.apiv3(editorSettings);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('getting-editor-settings-failed');
-    }
-  });
+  router.get(
+    '/editor-settings',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.OTHER]),
+    loginRequiredStrictly,
+    async (req, res) => {
+      try {
+        const query = { userId: req.user.id };
+        const editorSettings =
+          (await EditorSettings.findOne(query)) ?? new EditorSettings();
+        return res.apiv3(editorSettings);
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err('getting-editor-settings-failed');
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -758,9 +862,14 @@ module.exports = (crowi) => {
    *                schema:
    *                 type: object
    */
-  router.put('/in-app-notification-settings',
+  router.put(
+    '/in-app-notification-settings',
     accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION]),
-    loginRequiredStrictly, addActivity, validator.inAppNotificationSettings, apiV3FormValidator, async(req, res) => {
+    loginRequiredStrictly,
+    addActivity,
+    validator.inAppNotificationSettings,
+    apiV3FormValidator,
+    async (req, res) => {
       const query = { userId: req.user.id };
       const subscribeRules = req.body.subscribeRules;
 
@@ -770,18 +879,25 @@ module.exports = (crowi) => {
 
       const options = { upsert: true, new: true, runValidators: true };
       try {
-        const response = await InAppNotificationSettings.findOneAndUpdate(query, { $set: { subscribeRules } }, options);
-
-        const parameters = { action: SupportedAction.ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE };
+        const response = await InAppNotificationSettings.findOneAndUpdate(
+          query,
+          { $set: { subscribeRules } },
+          options,
+        );
+
+        const parameters = {
+          action:
+            SupportedAction.ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3(response);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err('updating-in-app-notification-settings-failed');
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -802,17 +918,21 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: InAppNotificationSettings
    */
-  router.get('/in-app-notification-settings', accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION]), loginRequiredStrictly, async(req, res) => {
-    const query = { userId: req.user.id };
-    try {
-      const response = await InAppNotificationSettings.findOne(query);
-      return res.apiv3(response);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('getting-in-app-notification-settings-failed');
-    }
-  });
+  router.get(
+    '/in-app-notification-settings',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION]),
+    loginRequiredStrictly,
+    async (req, res) => {
+      const query = { userId: req.user.id };
+      try {
+        const response = await InAppNotificationSettings.findOne(query);
+        return res.apiv3(response);
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err('getting-in-app-notification-settings-failed');
+      }
+    },
+  );
 
   return router;
 };

+ 13 - 5
apps/app/src/server/routes/apiv3/security-settings/checkSetupStrategiesHasAdmin.ts

@@ -6,7 +6,7 @@ interface AggregateResult {
   count: number;
 }
 
-const checkLocalStrategyHasAdmin = async(): Promise<boolean> => {
+const checkLocalStrategyHasAdmin = async (): Promise<boolean> => {
   const User = mongoose.model('User') as any;
 
   const localAdmins: AggregateResult[] = await User.aggregate([
@@ -23,7 +23,9 @@ const checkLocalStrategyHasAdmin = async(): Promise<boolean> => {
   return localAdmins.length > 0 && localAdmins[0].count > 0;
 };
 
-const checkExternalStrategiesHasAdmin = async(setupExternalStrategies: IExternalAuthProviderType[]): Promise<boolean> => {
+const checkExternalStrategiesHasAdmin = async (
+  setupExternalStrategies: IExternalAuthProviderType[],
+): Promise<boolean> => {
   const User = mongoose.model('User') as any;
 
   const externalAdmins: AggregateResult[] = await User.aggregate([
@@ -47,7 +49,9 @@ const checkExternalStrategiesHasAdmin = async(setupExternalStrategies: IExternal
   return externalAdmins.length > 0 && externalAdmins[0].count > 0;
 };
 
-export const checkSetupStrategiesHasAdmin = async(setupStrategies: (IExternalAuthProviderType | 'local')[]): Promise<boolean> => {
+export const checkSetupStrategiesHasAdmin = async (
+  setupStrategies: (IExternalAuthProviderType | 'local')[],
+): Promise<boolean> => {
   if (setupStrategies.includes('local')) {
     const isLocalStrategyHasAdmin = await checkLocalStrategyHasAdmin();
     if (isLocalStrategyHasAdmin) {
@@ -55,12 +59,16 @@ export const checkSetupStrategiesHasAdmin = async(setupStrategies: (IExternalAut
     }
   }
 
-  const setupExternalStrategies = setupStrategies.filter(strategy => strategy !== 'local') as IExternalAuthProviderType[];
+  const setupExternalStrategies = setupStrategies.filter(
+    (strategy) => strategy !== 'local',
+  ) as IExternalAuthProviderType[];
   if (setupExternalStrategies.length === 0) {
     return false;
   }
 
-  const isExternalStrategiesHasAdmin = await checkExternalStrategiesHasAdmin(setupExternalStrategies);
+  const isExternalStrategiesHasAdmin = await checkExternalStrategiesHasAdmin(
+    setupExternalStrategies,
+  );
 
   return isExternalStrategiesHasAdmin;
 };

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


+ 145 - 84
apps/app/src/server/routes/apiv3/user-activation.ts

@@ -1,10 +1,9 @@
-import path from 'path';
-
 import type { IUser } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { format, subSeconds } from 'date-fns';
 import { body, validationResult } from 'express-validator';
 import mongoose from 'mongoose';
+import path from 'path';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { RegistrationMode } from '~/interfaces/registration-mode';
@@ -34,7 +33,9 @@ export const completeRegistrationRules = () => {
       .matches(/^[\x20-\x7F]*$/)
       .withMessage('Password has invalid character')
       .isLength({ min: PASSOWRD_MINIMUM_NUMBER })
-      .withMessage('Password minimum character should be more than 8 characters')
+      .withMessage(
+        'Password minimum character should be more than 8 characters',
+      )
       .not()
       .isEmpty()
       .withMessage('Password field is required'),
@@ -49,12 +50,19 @@ export const validateCompleteRegistration = (req, res, next) => {
   }
 
   const extractedErrors: string[] = [];
-  errors.array().map(err => extractedErrors.push(err.msg));
+  errors.array().map((err) => extractedErrors.push(err.msg));
 
   return res.apiv3Err(extractedErrors);
 };
 
-async function sendEmailToAllAdmins(userData, admins, appTitle, mailService, template, url) {
+async function sendEmailToAllAdmins(
+  userData,
+  admins,
+  appTitle,
+  mailService,
+  template,
+  url,
+) {
   admins.map((admin) => {
     return mailService.send({
       to: admin.email,
@@ -110,29 +118,47 @@ async function sendEmailToAllAdmins(userData, admins, appTitle, mailService, tem
  *                   type: string
  */
 export const completeRegistrationAction = (crowi: Crowi) => {
-  const User = mongoose.model<IUser, { isEmailValid, isRegisterable, createUserByEmailAndPassword, findAdmins }>('User');
+  const User = mongoose.model<
+    IUser,
+    { isEmailValid; isRegisterable; createUserByEmailAndPassword; findAdmins }
+  >('User');
   const activityEvent = crowi.event('activity');
-  const {
-    aclService,
-    appService,
-    mailService,
-  } = crowi;
+  const { aclService, appService, mailService } = crowi;
 
-  return async function(req, res) {
+  return async (req, res) => {
     const { t } = await getTranslation();
 
     if (req.user != null) {
-      return res.apiv3Err(new ErrorV3('You have been logged in', 'registration-failed'), 403);
+      return res.apiv3Err(
+        new ErrorV3('You have been logged in', 'registration-failed'),
+        403,
+      );
     }
 
     // error when registration is not allowed
-    if (configManager.getConfig('security:registrationMode') === aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED) {
-      return res.apiv3Err(new ErrorV3('Registration closed', 'registration-failed'), 403);
+    if (
+      configManager.getConfig('security:registrationMode') ===
+      aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED
+    ) {
+      return res.apiv3Err(
+        new ErrorV3('Registration closed', 'registration-failed'),
+        403,
+      );
     }
 
     // error when email authentication is disabled
-    if (configManager.getConfig('security:passport-local:isEmailAuthenticationEnabled') !== true) {
-      return res.apiv3Err(new ErrorV3('Email authentication configuration is disabled', 'registration-failed'), 403);
+    if (
+      configManager.getConfig(
+        'security:passport-local:isEmailAuthenticationEnabled',
+      ) !== true
+    ) {
+      return res.apiv3Err(
+        new ErrorV3(
+          'Email authentication configuration is disabled',
+          'registration-failed',
+        ),
+        403,
+      );
     }
 
     const { userRegistrationOrder } = req;
@@ -162,65 +188,94 @@ export const completeRegistrationAction = (crowi: Crowi) => {
         }
       }
       if (isError) {
-        return res.apiv3Err(new ErrorV3(errorMessage, 'registration-failed'), 403);
+        return res.apiv3Err(
+          new ErrorV3(errorMessage, 'registration-failed'),
+          403,
+        );
       }
 
-      User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
-        if (err) {
-          if (err.name === 'UserUpperLimitException') {
-            errorMessage = t('message.can_not_register_maximum_number_of_users');
-          }
-          else {
-            errorMessage = t('message.failed_to_register');
-          }
-          return res.apiv3Err(new ErrorV3(errorMessage, 'registration-failed'), 403);
-        }
-
-        const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
-        activityEvent.emit('update', res.locals.activity._id, parameters);
-
-        userRegistrationOrder.revokeOneTimeToken();
-
-        if (configManager.getConfig('security:registrationMode') === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
-          const isMailerSetup = mailService.isMailerSetup ?? false;
-
-          if (isMailerSetup) {
-            const admins = await User.findAdmins();
-            const appTitle = appService.getAppTitle();
-            const locale = configManager.getConfig('app:globalLang');
-            const template = path.join(crowi.localeDir, `${locale}/admin/userWaitingActivation.ejs`);
-            const url = growiInfoService.getSiteUrl();
-
-            sendEmailToAllAdmins(userData, admins, appTitle, mailService, template, url);
-          }
-          // This 'completeRegistrationAction' should not be able to be called if the email settings is not set up in the first place.
-          // So this method dows not stop processing as an error, but only displays a warning. -- 2022.11.01 Yuki Takei
-          else {
-            logger.warn('E-mail Settings must be set up.');
-          }
-
-          return res.apiv3({});
-        }
-
-        req.login(userData, (err) => {
+      User.createUserByEmailAndPassword(
+        name,
+        username,
+        email,
+        password,
+        undefined,
+        async (err, userData) => {
           if (err) {
-            logger.debug(err);
+            if (err.name === 'UserUpperLimitException') {
+              errorMessage = t(
+                'message.can_not_register_maximum_number_of_users',
+              );
+            } else {
+              errorMessage = t('message.failed_to_register');
+            }
+            return res.apiv3Err(
+              new ErrorV3(errorMessage, 'registration-failed'),
+              403,
+            );
           }
-          else {
-            // update lastLoginAt
-            userData.updateLastLoginAt(new Date(), (err) => {
-              if (err) {
-                logger.error(`updateLastLoginAt dumps error: ${err}`);
-              }
-            });
+
+          const parameters = {
+            action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS,
+          };
+          activityEvent.emit('update', res.locals.activity._id, parameters);
+
+          userRegistrationOrder.revokeOneTimeToken();
+
+          if (
+            configManager.getConfig('security:registrationMode') ===
+            aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED
+          ) {
+            const isMailerSetup = mailService.isMailerSetup ?? false;
+
+            if (isMailerSetup) {
+              const admins = await User.findAdmins();
+              const appTitle = appService.getAppTitle();
+              const locale = configManager.getConfig('app:globalLang');
+              const template = path.join(
+                crowi.localeDir,
+                `${locale}/admin/userWaitingActivation.ejs`,
+              );
+              const url = growiInfoService.getSiteUrl();
+
+              sendEmailToAllAdmins(
+                userData,
+                admins,
+                appTitle,
+                mailService,
+                template,
+                url,
+              );
+            }
+            // This 'completeRegistrationAction' should not be able to be called if the email settings is not set up in the first place.
+            // So this method dows not stop processing as an error, but only displays a warning. -- 2022.11.01 Yuki Takei
+            else {
+              logger.warn('E-mail Settings must be set up.');
+            }
+
+            return res.apiv3({});
           }
 
-          // userData.password can't be empty but, prepare redirect because password property in User Model is optional
-          // https://github.com/growilabs/growi/pull/6670
-          const redirectTo = userData.password != null ? '/' : '/me#password_settings';
-          return res.apiv3({ redirectTo });
-        });
-      });
+          req.login(userData, (err) => {
+            if (err) {
+              logger.debug(err);
+            } else {
+              // update lastLoginAt
+              userData.updateLastLoginAt(new Date(), (err) => {
+                if (err) {
+                  logger.error(`updateLastLoginAt dumps error: ${err}`);
+                }
+              });
+            }
+
+            // userData.password can't be empty but, prepare redirect because password property in User Model is optional
+            // https://github.com/growilabs/growi/pull/6670
+            const redirectTo =
+              userData.password != null ? '/' : '/me#password_settings';
+            return res.apiv3({ redirectTo });
+          });
+        },
+      );
     });
   };
 };
@@ -244,17 +299,13 @@ export const validateRegisterForm = (req, res, next) => {
   }
 
   const extractedErrors: string[] = [];
-  errors.array().map(err => extractedErrors.push(err.msg));
+  errors.array().map((err) => extractedErrors.push(err.msg));
 
   return res.apiv3Err(extractedErrors, 400);
 };
 
 async function makeRegistrationEmailToken(email, crowi: Crowi) {
-  const {
-    mailService,
-    localeDir,
-    appService,
-  } = crowi;
+  const { mailService, localeDir, appService } = crowi;
 
   const isMailerSetup = mailService.isMailerSetup ?? false;
   if (!isMailerSetup) {
@@ -264,17 +315,24 @@ async function makeRegistrationEmailToken(email, crowi: Crowi) {
   const locale = configManager.getConfig('app:globalLang');
   const appUrl = growiInfoService.getSiteUrl();
 
-  const userRegistrationOrder = await UserRegistrationOrder.createUserRegistrationOrder(email);
+  const userRegistrationOrder =
+    await UserRegistrationOrder.createUserRegistrationOrder(email);
   const grwTzoffsetSec = crowi.appService.getTzoffset() * 60;
   const expiredAt = subSeconds(userRegistrationOrder.expiredAt, grwTzoffsetSec);
   const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
-  const url = new URL(`/user-activation/${userRegistrationOrder.token}`, appUrl);
+  const url = new URL(
+    `/user-activation/${userRegistrationOrder.token}`,
+    appUrl,
+  );
   const oneTimeUrl = url.href;
 
   return mailService.send({
     to: email,
     subject: '[GROWI] User Activation',
-    template: path.join(localeDir, `${locale}/notifications/userActivation.ejs`),
+    template: path.join(
+      localeDir,
+      `${locale}/notifications/userActivation.ejs`,
+    ),
     vars: {
       appTitle: appService.getAppTitle(),
       email,
@@ -285,13 +343,17 @@ async function makeRegistrationEmailToken(email, crowi: Crowi) {
 }
 
 export const registerAction = (crowi: Crowi) => {
-  const User = mongoose.model<IUser, { isRegisterableEmail, isEmailValid }>('User');
+  const User = mongoose.model<IUser, { isRegisterableEmail; isEmailValid }>(
+    'User',
+  );
 
-  return async function(req, res) {
+  return async (req, res) => {
     const registerForm = req.body.registerForm || {};
     const email = registerForm.email;
     const isRegisterableEmail = await User.isRegisterableEmail(email);
-    const registrationMode = configManager.getConfig('security:registrationMode');
+    const registrationMode = configManager.getConfig(
+      'security:registrationMode',
+    );
     const isEmailValid = await User.isEmailValid(email);
 
     if (registrationMode === RegistrationMode.CLOSED) {
@@ -309,8 +371,7 @@ export const registerAction = (crowi: Crowi) => {
 
     try {
       await makeRegistrationEmailToken(email, crowi);
-    }
-    catch (err) {
+    } catch (err) {
       return res.apiv3Err(err);
     }
 

+ 46 - 26
apps/app/src/server/routes/apiv3/user-activities.ts

@@ -3,7 +3,7 @@ import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import type { Request, Router } from 'express';
 import express from 'express';
 import { query } from 'express-validator';
-import type { PipelineStage, PaginateResult } from 'mongoose';
+import type { PaginateResult, PipelineStage } from 'mongoose';
 import { Types } from 'mongoose';
 
 import type { IActivity } from '~/interfaces/activity';
@@ -12,22 +12,32 @@ import Activity from '~/server/models/activity';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
-
 import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
 const logger = loggerFactory('growi:routes:apiv3:activity');
 
 const validator = {
   list: [
-    query('limit').optional().isInt({ max: 100 }).withMessage('limit must be a number less than or equal to 100')
+    query('limit')
+      .optional()
+      .isInt({ max: 100 })
+      .withMessage('limit must be a number less than or equal to 100')
       .toInt(),
-    query('offset').optional().isInt().withMessage('page must be a number')
+    query('offset')
+      .optional()
+      .isInt()
+      .withMessage('page must be a number')
       .toInt(),
-    query('searchFilter').optional().isString().withMessage('query must be a string'),
-    query('targetUserId').optional().isMongoId().withMessage('user ID must be a MongoDB ID'),
+    query('searchFilter')
+      .optional()
+      .isString()
+      .withMessage('query must be a string'),
+    query('targetUserId')
+      .optional()
+      .isMongoId()
+      .withMessage('user ID must be a MongoDB ID'),
   ],
 };
 
@@ -41,17 +51,16 @@ interface StrictActivityQuery {
 type CustomRequest<
   TQuery = Request['query'],
   TBody = any,
-  TParams = any
+  TParams = any,
 > = Omit<Request<TParams, any, TBody, TQuery>, 'query'> & {
-    query: TQuery & Request['query'];
-    user?: IUserHasId;
+  query: TQuery & Request['query'];
+  user?: IUserHasId;
 };
 
 type AuthorizedRequest = CustomRequest<StrictActivityQuery>;
 
 type ActivityPaginationResult = PaginateResult<IActivity>;
 
-
 /**
  * @swagger
  *
@@ -134,7 +143,9 @@ type ActivityPaginationResult = PaginateResult<IActivity>;
  */
 
 module.exports = (crowi: Crowi): Router => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(
+    crowi,
+  );
 
   const router = express.Router();
 
@@ -173,10 +184,15 @@ module.exports = (crowi: Crowi): Router => {
    *             schema:
    *               $ref: '#/components/schemas/ActivityResponse'
    */
-  router.get('/',
-    loginRequiredStrictly, validator.list, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
-
-      const defaultLimit = configManager.getConfig('customize:showPageLimitationS');
+  router.get(
+    '/',
+    loginRequiredStrictly,
+    validator.list,
+    apiV3FormValidator,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
+      const defaultLimit = configManager.getConfig(
+        'customize:showPageLimitationS',
+      );
 
       const limit = req.query.limit || defaultLimit || 10;
       const offset = req.query.offset || 0;
@@ -187,10 +203,12 @@ module.exports = (crowi: Crowi): Router => {
       }
 
       if (!targetUserId) {
-        return res.apiv3Err('Target user ID is missing and authenticated user ID is unavailable.', 400);
+        return res.apiv3Err(
+          'Target user ID is missing and authenticated user ID is unavailable.',
+          400,
+        );
       }
 
-
       try {
         const userObjectId = new Types.ObjectId(targetUserId);
 
@@ -203,9 +221,7 @@ module.exports = (crowi: Crowi): Router => {
           },
           {
             $facet: {
-              totalCount: [
-                { $count: 'count' },
-              ],
+              totalCount: [{ $count: 'count' }],
               docs: [
                 { $sort: { createdAt: -1 } },
                 { $skip: offset },
@@ -256,7 +272,8 @@ module.exports = (crowi: Crowi): Router => {
           },
         ];
 
-        const [activityResults] = await Activity.aggregate(userActivityPipeline);
+        const [activityResults] =
+          await Activity.aggregate(userActivityPipeline);
 
         const serializedResults = activityResults.docs.map((doc: IActivity) => {
           const { user, ...rest } = doc;
@@ -266,7 +283,10 @@ module.exports = (crowi: Crowi): Router => {
           };
         });
 
-        const totalDocs = activityResults.totalCount.length > 0 ? activityResults.totalCount[0].count : 0;
+        const totalDocs =
+          activityResults.totalCount.length > 0
+            ? activityResults.totalCount[0].count
+            : 0;
         const totalPages = Math.ceil(totalDocs / limit);
         const page = Math.floor(offset / limit) + 1;
 
@@ -289,12 +309,12 @@ module.exports = (crowi: Crowi): Router => {
         };
 
         return res.apiv3({ serializedPaginationResult });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Failed to get paginated activity', err);
         return res.apiv3Err(err, 500);
       }
-    });
+    },
+  );
 
   return router;
 };

+ 51 - 45
apps/app/src/server/routes/apiv3/user-ui-settings.ts

@@ -13,10 +13,13 @@ const logger = loggerFactory('growi:routes:apiv3:user-ui-settings');
 const router = express.Router();
 
 module.exports = () => {
-
   const validatorForPut = [
-    body('settings').exists().withMessage('The body param \'settings\' is required'),
-    body('settings.currentSidebarContents').optional().isIn(AllSidebarContentsType),
+    body('settings')
+      .exists()
+      .withMessage("The body param 'settings' is required"),
+    body('settings.currentSidebarContents')
+      .optional()
+      .isIn(AllSidebarContentsType),
     body('settings.currentProductNavWidth').optional().isNumeric(),
     body('settings.preferCollapsedModeByUser').optional().isBoolean(),
   ];
@@ -67,55 +70,58 @@ module.exports = () => {
    *                   type: boolean
    */
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  router.put('/', validatorForPut, apiV3FormValidator, async(req: any, res: any) => {
-    const { user } = req;
-    const { settings } = req.body;
+  router.put(
+    '/',
+    validatorForPut,
+    apiV3FormValidator,
+    async (req: any, res: any) => {
+      const { user } = req;
+      const { settings } = req.body;
 
-    // extract only necessary params
-    const updateData = {
-      currentSidebarContents: settings.currentSidebarContents,
-      currentProductNavWidth: settings.currentProductNavWidth,
-      preferCollapsedModeByUser: settings.preferCollapsedModeByUser,
-    };
+      // extract only necessary params
+      const updateData = {
+        currentSidebarContents: settings.currentSidebarContents,
+        currentProductNavWidth: settings.currentProductNavWidth,
+        preferCollapsedModeByUser: settings.preferCollapsedModeByUser,
+      };
 
-    if (user == null) {
-      if (req.session.uiSettings == null) {
-        req.session.uiSettings = {};
+      if (user == null) {
+        if (req.session.uiSettings == null) {
+          req.session.uiSettings = {};
+        }
+        Object.keys(updateData).forEach((setting) => {
+          if (updateData[setting] != null) {
+            req.session.uiSettings[setting] = updateData[setting];
+          }
+        });
+        return res.apiv3(updateData);
       }
-      Object.keys(updateData).forEach((setting) => {
-        if (updateData[setting] != null) {
-          req.session.uiSettings[setting] = updateData[setting];
+
+      // remove the keys that have null value
+      Object.keys(updateData).forEach((key) => {
+        if (updateData[key] == null) {
+          delete updateData[key];
         }
       });
-      return res.apiv3(updateData);
-    }
-
 
-    // remove the keys that have null value
-    Object.keys(updateData).forEach((key) => {
-      if (updateData[key] == null) {
-        delete updateData[key];
-      }
-    });
-
-    try {
-      const updatedSettings = await UserUISettings.findOneAndUpdate(
-        { user: user._id },
-        {
-          $set: {
-            user: user._id,
-            ...updateData,
+      try {
+        const updatedSettings = await UserUISettings.findOneAndUpdate(
+          { user: user._id },
+          {
+            $set: {
+              user: user._id,
+              ...updateData,
+            },
           },
-        },
-        { upsert: true, new: true },
-      );
-      return res.apiv3(updatedSettings);
-    }
-    catch (err) {
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(err));
-    }
-  });
+          { upsert: true, new: true },
+        );
+        return res.apiv3(updatedSettings);
+      } catch (err) {
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(err));
+      }
+    },
+  );
 
   return router;
 };

+ 17 - 10
apps/app/src/server/routes/apiv3/user/get-related-groups.ts

@@ -1,8 +1,8 @@
 import type { IUserHasId } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import loggerFactory from '~/utils/logger';
@@ -14,22 +14,29 @@ const logger = loggerFactory('growi:routes:apiv3:user:get-related-groups');
 type GetRelatedGroupsHandlerFactory = (crowi: Crowi) => RequestHandler[];
 
 interface Req extends Request {
-  user: IUserHasId,
+  user: IUserHasId;
 }
 
-export const getRelatedGroupsHandlerFactory: GetRelatedGroupsHandlerFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+export const getRelatedGroupsHandlerFactory: GetRelatedGroupsHandlerFactory = (
+  crowi,
+) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
+    crowi,
+  );
 
   return [
-    accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO], { acceptLegacy: true }), loginRequiredStrictly,
-    async(req: Req, res: ApiV3Response) => {
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    async (req: Req, res: ApiV3Response) => {
       try {
-        const relatedGroups = await crowi.pageGrantService?.getUserRelatedGroups(req.user);
+        const relatedGroups =
+          await crowi.pageGrantService?.getUserRelatedGroups(req.user);
         return res.apiv3({ relatedGroups });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('Error occurred while getting user related groups'));
+        return res.apiv3Err(
+          new ErrorV3('Error occurred while getting user related groups'),
+        );
       }
     },
   ];

+ 16 - 8
apps/app/src/server/service/attachment.js → apps/app/src/server/service/attachment.ts

@@ -1,5 +1,8 @@
+import type { IAttachment } from '@growi/core/dist/interfaces';
+
 import loggerFactory from '~/utils/logger';
 
+import type Crowi from '../crowi';
 import { AttachmentType } from '../interfaces/attachment';
 import { Attachment } from '../models/attachment';
 
@@ -16,22 +19,23 @@ const createReadStream = (filePath) => {
   });
 };
 
+type AttachHandler = (pageId: string | null, attachment: IAttachment, file: Express.Multer.File) => Promise<void>;
+
+type DetachHandler = (attachmentId: string) => Promise<void>;
+
+
 /**
  * the service class for Attachment and file-uploader
  */
 class AttachmentService {
 
-  /** @type {Array<(pageId: string, attachment: Attachment, file: Express.Multer.File) => Promise<void>>} */
-  attachHandlers = [];
+  attachHandlers: AttachHandler[] = [];
 
-  /** @type {Array<(attachmentId: string) => Promise<void>>} */
-  detachHandlers = [];
+  detachHandlers: DetachHandler[] = [];
 
-  /** @type {import('~/server/crowi').default} Crowi instance */
-  crowi;
+  crowi: Crowi;
 
-  /** @param {import('~/server/crowi').default} crowi Crowi instance */
-  constructor(crowi) {
+  constructor(crowi: Crowi) {
     this.crowi = crowi;
   }
 
@@ -101,6 +105,10 @@ class AttachmentService {
     const { fileUploadService } = this.crowi;
     const attachment = await Attachment.findById(attachmentId);
 
+    if (attachment == null) {
+      throw new Error(`Attachment not found: ${attachmentId}`);
+    }
+
     await fileUploadService.deleteFile(attachment);
     await attachment.remove();
 

+ 44 - 51
apps/app/src/server/service/file-uploader/aws/index.ts

@@ -166,8 +166,50 @@ class AwsFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override deleteFiles() {
-    throw new Error('Method not implemented.');
+  override async deleteFile(attachment: IAttachmentDocument): Promise<void> {
+    const filePath = getFilePathOnStorage(attachment);
+    return this.deleteFileByFilePath(filePath);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override async deleteFiles(attachments: IAttachmentDocument[]): Promise<void> {
+    if (!this.getIsUploadable()) {
+      throw new Error('AWS is not configured.');
+    }
+    const s3 = S3Factory();
+
+    const filePaths = attachments.map((attachment) => {
+      return { Key: getFilePathOnStorage(attachment) };
+    });
+
+    const totalParams = {
+      Bucket: getS3Bucket(),
+      Delete: { Objects: filePaths },
+    };
+    await s3.send(new DeleteObjectsCommand(totalParams));
+  }
+
+  private async deleteFileByFilePath(filePath: string): Promise<void> {
+    if (!this.getIsUploadable()) {
+      throw new Error('AWS is not configured.');
+    }
+    const s3 = S3Factory();
+
+    const params = {
+      Bucket: getS3Bucket(),
+      Key: filePath,
+    };
+
+    // check file exists
+    const isExists = await isFileExists(s3, params);
+    if (!isExists) {
+      logger.warn(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
+      return;
+    }
+
+    await s3.send(new DeleteObjectCommand(params));
   }
 
   /**
@@ -345,49 +387,6 @@ module.exports = (crowi: Crowi) => {
       && configManager.getConfig('aws:s3Bucket') != null;
   };
 
-  (lib as any).deleteFile = async function(attachment) {
-    const filePath = getFilePathOnStorage(attachment);
-    return (lib as any).deleteFileByFilePath(filePath);
-  };
-
-  (lib as any).deleteFiles = async function(attachments) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('AWS is not configured.');
-    }
-    const s3 = S3Factory();
-
-    const filePaths = attachments.map((attachment) => {
-      return { Key: getFilePathOnStorage(attachment) };
-    });
-
-    const totalParams = {
-      Bucket: getS3Bucket(),
-      Delete: { Objects: filePaths },
-    };
-    return s3.send(new DeleteObjectsCommand(totalParams));
-  };
-
-  (lib as any).deleteFileByFilePath = async function(filePath) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('AWS is not configured.');
-    }
-    const s3 = S3Factory();
-
-    const params = {
-      Bucket: getS3Bucket(),
-      Key: filePath,
-    };
-
-    // check file exists
-    const isExists = await isFileExists(s3, params);
-    if (!isExists) {
-      logger.warn(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
-      return;
-    }
-
-    return s3.send(new DeleteObjectCommand(params));
-  };
-
   lib.saveFile = async function({ filePath, contentType, data }) {
     const s3 = S3Factory();
 
@@ -400,12 +399,6 @@ module.exports = (crowi: Crowi) => {
     }));
   };
 
-  (lib as any).checkLimit = async function(uploadFileSize) {
-    const maxFileSize = configManager.getConfig('app:maxFileSize');
-    const totalLimit = configManager.getConfig('app:fileUploadTotalLimit');
-    return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
-  };
-
   /**
    * List files in storage
    */

+ 21 - 28
apps/app/src/server/service/file-uploader/azure.ts

@@ -161,8 +161,27 @@ class AzureFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override deleteFiles() {
-    throw new Error('Method not implemented.');
+  override async deleteFile(attachment: IAttachmentDocument): Promise<void> {
+    const filePath = getFilePathOnStorage(attachment);
+    const containerClient = await getContainerClient();
+    const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
+    const options: BlobDeleteOptions = { deleteSnapshots: 'include' };
+    const blobDeleteIfExistsResponse: BlobDeleteIfExistsResponse = await blockBlobClient.deleteIfExists(options);
+    if (!blobDeleteIfExistsResponse.errorCode) {
+      logger.info(`deleted blob ${filePath}`);
+    }
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override async deleteFiles(attachments: IAttachmentDocument[]): Promise<void> {
+    if (!this.getIsUploadable()) {
+      throw new Error('Azure is not configured.');
+    }
+    for await (const attachment of attachments) {
+      await this.deleteFile(attachment);
+    }
   }
 
   /**
@@ -312,26 +331,6 @@ module.exports = (crowi: Crowi) => {
       && configManager.getConfig('azure:storageContainerName') != null;
   };
 
-  (lib as any).deleteFile = async function(attachment) {
-    const filePath = getFilePathOnStorage(attachment);
-    const containerClient = await getContainerClient();
-    const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
-    const options: BlobDeleteOptions = { deleteSnapshots: 'include' };
-    const blobDeleteIfExistsResponse: BlobDeleteIfExistsResponse = await blockBlobClient.deleteIfExists(options);
-    if (!blobDeleteIfExistsResponse.errorCode) {
-      logger.info(`deleted blob ${filePath}`);
-    }
-  };
-
-  (lib as any).deleteFiles = async function(attachments) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('Azure is not configured.');
-    }
-    for await (const attachment of attachments) {
-      (lib as any).deleteFile(attachment);
-    }
-  };
-
   lib.saveFile = async function({ filePath, contentType, data }) {
     const containerClient = await getContainerClient();
     const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
@@ -345,12 +344,6 @@ module.exports = (crowi: Crowi) => {
     return;
   };
 
-  (lib as any).checkLimit = async function(uploadFileSize) {
-    const maxFileSize = configManager.getConfig('app:maxFileSize');
-    const gcsTotalLimit = configManager.getConfig('app:fileUploadTotalLimit');
-    return lib.doCheckLimit(uploadFileSize, maxFileSize, gcsTotalLimit);
-  };
-
   (lib as any).listFiles = async function() {
     if (!lib.getIsReadable()) {
       throw new Error('Azure is not configured.');

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

@@ -1,6 +1,7 @@
 import type { Readable } from 'stream';
 
 import type { Response } from 'express';
+import type { HydratedDocument } from 'mongoose';
 import { v4 as uuidv4 } from 'uuid';
 
 import type { ICheckLimitResult } from '~/interfaces/attachment';
@@ -35,10 +36,11 @@ export interface FileUploader {
   getFileUploadEnabled(): boolean,
   listFiles(): any,
   saveFile(param: SaveFileParam): Promise<any>,
-  deleteFiles(): void,
+  deleteFile(attachment: HydratedDocument<IAttachmentDocument>): void,
+  deleteFiles(attachments: HydratedDocument<IAttachmentDocument>[]): void,
   getFileUploadTotalLimit(): number,
   getTotalFileSize(): Promise<number>,
-  doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<ICheckLimitResult>,
+  checkLimit(uploadFileSize: number): Promise<ICheckLimitResult>,
   determineResponseMode(): ResponseMode,
   uploadAttachment(readable: Readable, attachment: IAttachmentDocument): Promise<void>,
   respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void,
@@ -103,20 +105,16 @@ export abstract class AbstractFileUploader implements FileUploader {
 
   abstract saveFile(param: SaveFileParam);
 
-  abstract deleteFiles();
+  abstract deleteFile(attachment: HydratedDocument<IAttachmentDocument>): void;
+
+  abstract deleteFiles(attachments: HydratedDocument<IAttachmentDocument>[]): void;
 
   /**
    * Returns file upload total limit in bytes.
-   * Reference to previous implementation is
-   * {@link https://github.com/growilabs/growi/blob/798e44f14ad01544c1d75ba83d4dfb321a94aa0b/src/server/service/file-uploader/gridfs.js#L86-L88}
    * @returns file upload total limit in bytes
    */
-  getFileUploadTotalLimit() {
-    const fileUploadTotalLimit = configManager.getConfig('app:fileUploadType') === 'mongodb'
-      // Use app:fileUploadTotalLimit if gridfs:totalLimit is null (default for gridfs:totalLimit is null)
-      ? configManager.getConfig('app:fileUploadTotalLimit')
-      : configManager.getConfig('app:fileUploadTotalLimit');
-    return fileUploadTotalLimit;
+  getFileUploadTotalLimit(): number {
+    return configManager.getConfig('app:fileUploadTotalLimit');
   }
 
   /**
@@ -134,11 +132,20 @@ export abstract class AbstractFileUploader implements FileUploader {
     return res.length === 0 ? 0 : res[0].total;
   }
 
+  /**
+   * check the file size limit
+   */
+  checkLimit(uploadFileSize: number): Promise<ICheckLimitResult> {
+    const maxFileSize = configManager.getConfig('app:maxFileSize');
+    const totalLimit = this.getFileUploadTotalLimit();
+    return this.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
+  }
+
   /**
    * Check files size limits for all uploaders
    *
    */
-  async doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<ICheckLimitResult> {
+  protected async doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<ICheckLimitResult> {
     if (uploadFileSize > maxFileSize) {
       return { isUploadable: false, errorMessage: 'File size exceeds the size limit per file' };
     }

+ 30 - 43
apps/app/src/server/service/file-uploader/gcs/index.ts

@@ -105,8 +105,36 @@ class GcsFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override deleteFiles() {
-    throw new Error('Method not implemented.');
+  override async deleteFile(attachment: IAttachmentDocument): Promise<void> {
+    const filePath = getFilePathOnStorage(attachment);
+    return this.deleteFilesByFilePaths([filePath]);
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override async deleteFiles(attachments: IAttachmentDocument[]): Promise<void> {
+    const filePaths = attachments.map((attachment) => {
+      return getFilePathOnStorage(attachment);
+    });
+    return this.deleteFilesByFilePaths(filePaths);
+  }
+
+  private async deleteFilesByFilePaths(filePaths: string[]): Promise<void> {
+    if (!this.getIsUploadable()) {
+      throw new Error('GCS is not configured.');
+    }
+
+    const gcs = getGcsInstance();
+    const myBucket = gcs.bucket(getGcsBucket());
+
+    const files = filePaths.map((filePath) => {
+      return myBucket.file(filePath);
+    });
+
+    files.forEach((file) => {
+      file.delete({ ignoreNotFound: true });
+    });
   }
 
   /**
@@ -263,35 +291,6 @@ module.exports = function(crowi: Crowi) {
       && configManager.getConfig('gcs:bucket') != null;
   };
 
-  (lib as any).deleteFile = function(attachment) {
-    const filePath = getFilePathOnStorage(attachment);
-    return (lib as any).deleteFilesByFilePaths([filePath]);
-  };
-
-  (lib as any).deleteFiles = function(attachments) {
-    const filePaths = attachments.map((attachment) => {
-      return getFilePathOnStorage(attachment);
-    });
-    return (lib as any).deleteFilesByFilePaths(filePaths);
-  };
-
-  (lib as any).deleteFilesByFilePaths = function(filePaths) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('GCS is not configured.');
-    }
-
-    const gcs = getGcsInstance();
-    const myBucket = gcs.bucket(getGcsBucket());
-
-    const files = filePaths.map((filePath) => {
-      return myBucket.file(filePath);
-    });
-
-    files.forEach((file) => {
-      file.delete({ ignoreNotFound: true });
-    });
-  };
-
   lib.saveFile = async function({ filePath, contentType, data }) {
     const gcs = getGcsInstance();
     const myBucket = gcs.bucket(getGcsBucket());
@@ -299,18 +298,6 @@ module.exports = function(crowi: Crowi) {
     return myBucket.file(filePath).save(data, { resumable: false });
   };
 
-  /**
-   * check the file size limit
-   *
-   * In detail, the followings are checked.
-   * - per-file size limit (specified by MAX_FILE_SIZE)
-   */
-  (lib as any).checkLimit = async function(uploadFileSize) {
-    const maxFileSize = configManager.getConfig('app:maxFileSize');
-    const gcsTotalLimit = configManager.getConfig('app:fileUploadTotalLimit');
-    return lib.doCheckLimit(uploadFileSize, maxFileSize, gcsTotalLimit);
-  };
-
   /**
    * List files in storage
    */

+ 42 - 47
apps/app/src/server/service/file-uploader/gridfs.ts

@@ -104,8 +104,48 @@ class GridfsFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override deleteFiles() {
-    throw new Error('Method not implemented.');
+  override async deleteFile(attachment: IAttachmentDocument): Promise<void> {
+    const { attachmentFileModel } = initializeGridFSModels();
+    const filenameValue = attachment.fileName;
+
+    const attachmentFile = await attachmentFileModel.findOne({ filename: filenameValue });
+
+    if (attachmentFile == null) {
+      logger.warn(`Any AttachmentFile that relate to the Attachment (${attachment._id.toString()}) does not exist in GridFS`);
+      return;
+    }
+
+    return attachmentFileModel.promisifiedUnlink({ _id: attachmentFile._id });
+  }
+
+  /**
+   * @inheritdoc
+   *
+   * Bulk delete files since unlink method of mongoose-gridfs does not support bulk operation
+   */
+  override async deleteFiles(attachments: IAttachmentDocument[]): Promise<void> {
+    const { attachmentFileModel, chunkCollection } = initializeGridFSModels();
+
+    const filenameValues = attachments.map((attachment) => {
+      return attachment.fileName;
+    });
+    const fileIdObjects = await attachmentFileModel.find({ filename: { $in: filenameValues } }, { _id: 1 });
+    const idsRelatedFiles = fileIdObjects.map((obj) => { return obj._id });
+
+    await Promise.all([
+      attachmentFileModel.deleteMany({ filename: { $in: filenameValues } }),
+      chunkCollection.deleteMany({ files_id: { $in: idsRelatedFiles } }),
+    ]);
+  }
+
+  /**
+   * @inheritdoc
+   *
+   * Reference to previous implementation is
+   * {@link https://github.com/growilabs/growi/blob/798e44f14ad01544c1d75ba83d4dfb321a94aa0b/src/server/service/file-uploader/gridfs.js#L86-L88}
+   */
+  override getFileUploadTotalLimit() {
+    return configManager.getConfig('gridfs:totalLimit') ?? configManager.getConfig('app:fileUploadTotalLimit');
   }
 
   /**
@@ -158,51 +198,6 @@ module.exports = function(crowi: Crowi) {
     return true;
   };
 
-  (lib as any).deleteFile = async function(attachment) {
-    const { attachmentFileModel } = initializeGridFSModels();
-    const filenameValue = attachment.fileName;
-
-    const attachmentFile = await attachmentFileModel.findOne({ filename: filenameValue });
-
-    if (attachmentFile == null) {
-      logger.warn(`Any AttachmentFile that relate to the Attachment (${attachment._id.toString()}) does not exist in GridFS`);
-      return;
-    }
-
-    return attachmentFileModel.promisifiedUnlink({ _id: attachmentFile._id });
-  };
-
-  /**
-   * Bulk delete files since unlink method of mongoose-gridfs does not support bulk operation
-   */
-  (lib as any).deleteFiles = async function(attachments) {
-    const { attachmentFileModel, chunkCollection } = initializeGridFSModels();
-
-    const filenameValues = attachments.map((attachment) => {
-      return attachment.fileName;
-    });
-    const fileIdObjects = await attachmentFileModel.find({ filename: { $in: filenameValues } }, { _id: 1 });
-    const idsRelatedFiles = fileIdObjects.map((obj) => { return obj._id });
-
-    return Promise.all([
-      attachmentFileModel.deleteMany({ filename: { $in: filenameValues } }),
-      chunkCollection.deleteMany({ files_id: { $in: idsRelatedFiles } }),
-    ]);
-  };
-
-  /**
-   * check the file size limit
-   *
-   * In detail, the followings are checked.
-   * - per-file size limit (specified by MAX_FILE_SIZE)
-   * - mongodb(gridfs) size limit (specified by MONGO_GRIDFS_TOTAL_LIMIT)
-   */
-  (lib as any).checkLimit = async function(uploadFileSize) {
-    const maxFileSize = configManager.getConfig('app:maxFileSize');
-    const totalLimit = lib.getFileUploadTotalLimit();
-    return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
-  };
-
   lib.saveFile = async function({ filePath, contentType, data }) {
     const { attachmentFileModel } = initializeGridFSModels();
 

+ 31 - 44
apps/app/src/server/service/file-uploader/local.ts

@@ -56,11 +56,34 @@ class LocalFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override deleteFiles() {
-    throw new Error('Method not implemented.');
+  override async deleteFile(attachment: IAttachmentDocument): Promise<void> {
+    const filePath = this.getFilePathOnStorage(attachment);
+    return this.deleteFileByFilePath(filePath);
   }
 
-  deleteFileByFilePath(filePath: string): void {
+  /**
+   * @inheritdoc
+   */
+  override async deleteFiles(attachments: IAttachmentDocument[]): Promise<void> {
+    await Promise.all(attachments.map((attachment) => {
+      return this.deleteFile(attachment);
+    }));
+  }
+
+  private async deleteFileByFilePath(filePath: string): Promise<void> {
+    // check file exists
+    try {
+      fs.statSync(filePath);
+    }
+    catch (err) {
+      logger.warn(`Any AttachmentFile which path is '${filePath}' does not exist in local fs`);
+      return;
+    }
+
+    return fs.unlinkSync(filePath);
+  }
+
+  getFilePathOnStorage(_attachment: IAttachmentDocument): string {
     throw new Error('Method not implemented.');
   }
 
@@ -108,14 +131,14 @@ module.exports = function(crowi: Crowi) {
 
   const basePath = path.posix.join(crowi.publicDir, 'uploads');
 
-  function getFilePathOnStorage(attachment: IAttachmentDocument) {
+  lib.getFilePathOnStorage = function(attachment: IAttachmentDocument) {
     const dirName = (attachment.page != null)
       ? FilePathOnStoragePrefix.attachment
       : FilePathOnStoragePrefix.user;
     const filePath = path.posix.join(basePath, dirName, attachment.fileName);
 
     return filePath;
-  }
+  };
 
   async function readdirRecursively(dirPath) {
     const directories = await fsPromises.readdir(dirPath, { withFileTypes: true });
@@ -131,34 +154,10 @@ module.exports = function(crowi: Crowi) {
     return true;
   };
 
-  (lib as any).deleteFile = async function(attachment) {
-    const filePath = getFilePathOnStorage(attachment);
-    return lib.deleteFileByFilePath(filePath);
-  };
-
-  (lib as any).deleteFiles = async function(attachments) {
-    attachments.map((attachment) => {
-      return (lib as any).deleteFile(attachment);
-    });
-  };
-
-  lib.deleteFileByFilePath = async function(filePath) {
-    // check file exists
-    try {
-      fs.statSync(filePath);
-    }
-    catch (err) {
-      logger.warn(`Any AttachmentFile which path is '${filePath}' does not exist in local fs`);
-      return;
-    }
-
-    return fs.unlinkSync(filePath);
-  };
-
   lib.uploadAttachment = async function(fileStream, attachment) {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
-    const filePath = getFilePathOnStorage(attachment);
+    const filePath = lib.getFilePathOnStorage(attachment);
     const dirpath = path.posix.dirname(filePath);
 
     // mkdir -p
@@ -211,7 +210,7 @@ module.exports = function(crowi: Crowi) {
    * @return {stream.Readable} readable stream
    */
   lib.findDeliveryFile = async function(attachment) {
-    const filePath = getFilePathOnStorage(attachment);
+    const filePath = lib.getFilePathOnStorage(attachment);
 
     // check file exists
     try {
@@ -225,18 +224,6 @@ module.exports = function(crowi: Crowi) {
     return fs.createReadStream(filePath);
   };
 
-  /**
-   * check the file size limit
-   *
-   * In detail, the followings are checked.
-   * - per-file size limit (specified by MAX_FILE_SIZE)
-   */
-  (lib as any).checkLimit = async function(uploadFileSize) {
-    const maxFileSize = configManager.getConfig('app:maxFileSize');
-    const totalLimit = configManager.getConfig('app:fileUploadTotalLimit');
-    return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
-  };
-
   /**
    * Respond to the HTTP request.
    * @param {Response} res
@@ -244,7 +231,7 @@ module.exports = function(crowi: Crowi) {
    */
   lib.respond = function(res, attachment, opts) {
     // Responce using internal redirect of nginx or Apache.
-    const storagePath = getFilePathOnStorage(attachment);
+    const storagePath = lib.getFilePathOnStorage(attachment);
     const relativePath = path.relative(crowi.publicDir, storagePath);
     const internalPathRoot = configManager.getConfig('fileUpload:local:internalRedirectPath');
     const internalPath = urljoin(internalPathRoot, relativePath);

+ 3 - 1
biome.json

@@ -30,7 +30,9 @@
       "!packages/pdf-converter-client/specs",
       "!apps/app/src/client",
       "!apps/app/src/server/middlewares",
-      "!apps/app/src/server/routes/apiv3",
+      "!apps/app/src/server/routes/apiv3/app-settings",
+      "!apps/app/src/server/routes/apiv3/page",
+      "!apps/app/src/server/routes/apiv3/*.js",
       "!apps/app/src/server/service"
     ]
   },

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