Explorar el Código

Merge remote-tracking branch 'origin/dev/7.4.x' into imprv/refactor-attachment-service

Yuki Takei hace 4 meses
padre
commit
cfc6ec6ec8
Se han modificado 38 ficheros con 4533 adiciones y 2481 borrados
  1. 8 0
      apps/app/.eslintrc.js
  2. 6 1
      apps/app/src/pages/common-props/commons.ts
  3. 50 37
      apps/app/src/server/routes/apiv3/activity.ts
  4. 26 15
      apps/app/src/server/routes/apiv3/admin-home.ts
  5. 72 37
      apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.integ.ts
  6. 173 94
      apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.ts
  7. 342 175
      apps/app/src/server/routes/apiv3/app-settings/index.ts
  8. 157 94
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  9. 315 157
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  10. 26 13
      apps/app/src/server/routes/apiv3/healthcheck.ts
  11. 199 141
      apps/app/src/server/routes/apiv3/import.ts
  12. 88 59
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  13. 68 49
      apps/app/src/server/routes/apiv3/installer.ts
  14. 2 2
      apps/app/src/server/routes/apiv3/interfaces/apiv3-response.ts
  15. 48 36
      apps/app/src/server/routes/apiv3/invited.ts
  16. 116 63
      apps/app/src/server/routes/apiv3/page-listing.ts
  17. 16 11
      apps/app/src/server/routes/apiv3/page/check-page-existence.ts
  18. 154 65
      apps/app/src/server/routes/apiv3/page/create-page.ts
  19. 71 50
      apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts
  20. 23 13
      apps/app/src/server/routes/apiv3/page/get-yjs-data.ts
  21. 474 275
      apps/app/src/server/routes/apiv3/page/index.ts
  22. 19 14
      apps/app/src/server/routes/apiv3/page/publish-page.ts
  23. 60 40
      apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts
  24. 19 14
      apps/app/src/server/routes/apiv3/page/unpublish-page.ts
  25. 153 56
      apps/app/src/server/routes/apiv3/page/update-page.ts
  26. 458 234
      apps/app/src/server/routes/apiv3/pages/index.js
  27. 44 32
      apps/app/src/server/routes/apiv3/personal-setting/delete-access-token.ts
  28. 40 32
      apps/app/src/server/routes/apiv3/personal-setting/delete-all-access-tokens.ts
  29. 51 42
      apps/app/src/server/routes/apiv3/personal-setting/generate-access-token.ts
  30. 18 11
      apps/app/src/server/routes/apiv3/personal-setting/get-access-tokens.ts
  31. 277 157
      apps/app/src/server/routes/apiv3/personal-setting/index.js
  32. 13 5
      apps/app/src/server/routes/apiv3/security-settings/checkSetupStrategiesHasAdmin.ts
  33. 687 291
      apps/app/src/server/routes/apiv3/security-settings/index.js
  34. 145 84
      apps/app/src/server/routes/apiv3/user-activation.ts
  35. 46 26
      apps/app/src/server/routes/apiv3/user-activities.ts
  36. 51 45
      apps/app/src/server/routes/apiv3/user-ui-settings.ts
  37. 17 10
      apps/app/src/server/routes/apiv3/user/get-related-groups.ts
  38. 1 1
      biome.json

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

@@ -64,6 +64,14 @@ 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/app-settings/**',
+    'src/server/routes/apiv3/page/**',
+    'src/server/routes/apiv3/*.ts',
   ],
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript

+ 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

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

+ 72 - 37
apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.integ.ts

@@ -17,25 +17,34 @@ const mockActivityId = '507f1f77bcf86cd799439011';
 mockRequire.stopAll();
 
 mockRequire('~/server/middlewares/access-token-parser', {
-  accessTokenParser: () => (_req: Request, _res: ApiV3Response, next: () => void) => next(),
+  accessTokenParser:
+    () => (_req: Request, _res: ApiV3Response, next: () => void) =>
+      next(),
 });
 
-mockRequire('../../../middlewares/login-required', () => (_req: Request, _res: ApiV3Response, next: () => void) => next());
-mockRequire('../../../middlewares/admin-required', () => (_req: Request, _res: ApiV3Response, next: () => void) => next());
+mockRequire(
+  '../../../middlewares/login-required',
+  () => (_req: Request, _res: ApiV3Response, next: () => void) => next(),
+);
+mockRequire(
+  '../../../middlewares/admin-required',
+  () => (_req: Request, _res: ApiV3Response, next: () => void) => next(),
+);
 
 mockRequire('../../../middlewares/add-activity', {
-  generateAddActivityMiddleware: () => (_req: Request, res: ApiV3Response, next: () => void) => {
-    res.locals = res.locals || {};
-    res.locals.activity = { _id: mockActivityId };
-    next();
-  },
+  generateAddActivityMiddleware:
+    () => (_req: Request, res: ApiV3Response, next: () => void) => {
+      res.locals = res.locals || {};
+      res.locals.activity = { _id: mockActivityId };
+      next();
+    },
 });
 
 describe('file-upload-setting route', () => {
   let app: express.Application;
   let crowiMock: Crowi;
 
-  beforeEach(async() => {
+  beforeEach(async () => {
     // Initialize configManager for each test
     const s2sMessagingServiceMock = mock<S2sMessagingService>();
     configManager.setS2sMessagingService(s2sMessagingServiceMock);
@@ -59,14 +68,16 @@ describe('file-upload-setting route', () => {
     // Mock apiv3 response methods
     app.use((_req, res, next) => {
       const apiRes = res as ApiV3Response;
-      apiRes.apiv3 = data => res.json(data);
-      apiRes.apiv3Err = (error, statusCode = 500) => res.status(statusCode).json({ error });
+      apiRes.apiv3 = (data) => res.json(data);
+      apiRes.apiv3Err = (error, statusCode = 500) =>
+        res.status(statusCode).json({ error });
       next();
     });
 
     // Import and mount the actual router using dynamic import
     const fileUploadSettingModule = await import('./file-upload-setting');
-    const fileUploadSettingRouterFactory = (fileUploadSettingModule as any).default || fileUploadSettingModule;
+    const fileUploadSettingRouterFactory =
+      (fileUploadSettingModule as any).default || fileUploadSettingModule;
     const fileUploadSettingRouter = fileUploadSettingRouterFactory(crowiMock);
     app.use('/', fileUploadSettingRouter);
   });
@@ -75,7 +86,7 @@ describe('file-upload-setting route', () => {
     mockRequire.stopAll();
   });
 
-  it('should update file upload type to local', async() => {
+  it('should update file upload type to local', async () => {
     const response = await request(app)
       .put('/')
       .send({
@@ -89,7 +100,7 @@ describe('file-upload-setting route', () => {
   });
 
   describe('AWS settings', () => {
-    const setupAwsSecret = async(secret: string) => {
+    const setupAwsSecret = async (secret: string) => {
       await configManager.updateConfigs({
         'app:fileUploadType': 'aws',
         'aws:s3SecretAccessKey': toNonBlankString(secret),
@@ -99,11 +110,13 @@ describe('file-upload-setting route', () => {
       await configManager.loadConfigs();
     };
 
-    it('should preserve existing s3SecretAccessKey when not included in request', async() => {
+    it('should preserve existing s3SecretAccessKey when not included in request', async () => {
       const existingSecret = 'existing-secret-key-12345';
       await setupAwsSecret(existingSecret);
 
-      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(existingSecret);
+      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(
+        existingSecret,
+      );
 
       const response = await request(app)
         .put('/')
@@ -117,15 +130,19 @@ describe('file-upload-setting route', () => {
 
       await configManager.loadConfigs();
 
-      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(existingSecret);
+      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(
+        existingSecret,
+      );
       expect(response.body.responseParams.fileUploadType).toBe('aws');
     });
 
-    it('should update s3SecretAccessKey when new value is provided in request', async() => {
+    it('should update s3SecretAccessKey when new value is provided in request', async () => {
       const existingSecret = 'existing-secret-key-12345';
       await setupAwsSecret(existingSecret);
 
-      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(existingSecret);
+      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(
+        existingSecret,
+      );
 
       const newSecret = 'new-secret-key-67890';
       const response = await request(app)
@@ -145,11 +162,13 @@ describe('file-upload-setting route', () => {
       expect(response.body.responseParams.fileUploadType).toBe('aws');
     });
 
-    it('should remove s3SecretAccessKey when empty string is provided in request', async() => {
+    it('should remove s3SecretAccessKey when empty string is provided in request', async () => {
       const existingSecret = 'existing-secret-key-12345';
       await setupAwsSecret(existingSecret);
 
-      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(existingSecret);
+      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(
+        existingSecret,
+      );
 
       const response = await request(app)
         .put('/')
@@ -170,7 +189,7 @@ describe('file-upload-setting route', () => {
   });
 
   describe('GCS settings', () => {
-    const setupGcsSecret = async(apiKeyPath: string) => {
+    const setupGcsSecret = async (apiKeyPath: string) => {
       await configManager.updateConfigs({
         'app:fileUploadType': 'gcs',
         'gcs:apiKeyJsonPath': toNonBlankString(apiKeyPath),
@@ -179,11 +198,13 @@ describe('file-upload-setting route', () => {
       await configManager.loadConfigs();
     };
 
-    it('should preserve existing gcsApiKeyJsonPath when not included in request', async() => {
+    it('should preserve existing gcsApiKeyJsonPath when not included in request', async () => {
       const existingApiKeyPath = '/path/to/existing-api-key.json';
       await setupGcsSecret(existingApiKeyPath);
 
-      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(existingApiKeyPath);
+      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(
+        existingApiKeyPath,
+      );
 
       const response = await request(app)
         .put('/')
@@ -196,15 +217,19 @@ describe('file-upload-setting route', () => {
 
       await configManager.loadConfigs();
 
-      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(existingApiKeyPath);
+      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(
+        existingApiKeyPath,
+      );
       expect(response.body.responseParams.fileUploadType).toBe('gcs');
     });
 
-    it('should update gcsApiKeyJsonPath when new value is provided in request', async() => {
+    it('should update gcsApiKeyJsonPath when new value is provided in request', async () => {
       const existingApiKeyPath = '/path/to/existing-api-key.json';
       await setupGcsSecret(existingApiKeyPath);
 
-      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(existingApiKeyPath);
+      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(
+        existingApiKeyPath,
+      );
 
       const newApiKeyPath = '/path/to/new-api-key.json';
       const response = await request(app)
@@ -223,11 +248,13 @@ describe('file-upload-setting route', () => {
       expect(response.body.responseParams.fileUploadType).toBe('gcs');
     });
 
-    it('should remove gcsApiKeyJsonPath when empty string is provided in request', async() => {
+    it('should remove gcsApiKeyJsonPath when empty string is provided in request', async () => {
       const existingApiKeyPath = '/path/to/existing-api-key.json';
       await setupGcsSecret(existingApiKeyPath);
 
-      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(existingApiKeyPath);
+      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(
+        existingApiKeyPath,
+      );
 
       const response = await request(app)
         .put('/')
@@ -247,7 +274,7 @@ describe('file-upload-setting route', () => {
   });
 
   describe('Azure settings', () => {
-    const setupAzureSecret = async(secret: string) => {
+    const setupAzureSecret = async (secret: string) => {
       await configManager.updateConfigs({
         'app:fileUploadType': 'azure',
         'azure:clientSecret': toNonBlankString(secret),
@@ -259,11 +286,13 @@ describe('file-upload-setting route', () => {
       await configManager.loadConfigs();
     };
 
-    it('should preserve existing azureClientSecret when not included in request', async() => {
+    it('should preserve existing azureClientSecret when not included in request', async () => {
       const existingSecret = 'existing-azure-secret-12345';
       await setupAzureSecret(existingSecret);
 
-      expect(configManager.getConfig('azure:clientSecret')).toBe(existingSecret);
+      expect(configManager.getConfig('azure:clientSecret')).toBe(
+        existingSecret,
+      );
 
       const response = await request(app)
         .put('/')
@@ -279,15 +308,19 @@ describe('file-upload-setting route', () => {
 
       await configManager.loadConfigs();
 
-      expect(configManager.getConfig('azure:clientSecret')).toBe(existingSecret);
+      expect(configManager.getConfig('azure:clientSecret')).toBe(
+        existingSecret,
+      );
       expect(response.body.responseParams.fileUploadType).toBe('azure');
     });
 
-    it('should update azureClientSecret when new value is provided in request', async() => {
+    it('should update azureClientSecret when new value is provided in request', async () => {
       const existingSecret = 'existing-azure-secret-12345';
       await setupAzureSecret(existingSecret);
 
-      expect(configManager.getConfig('azure:clientSecret')).toBe(existingSecret);
+      expect(configManager.getConfig('azure:clientSecret')).toBe(
+        existingSecret,
+      );
 
       const newSecret = 'new-azure-secret-67890';
       const response = await request(app)
@@ -309,11 +342,13 @@ describe('file-upload-setting route', () => {
       expect(response.body.responseParams.fileUploadType).toBe('azure');
     });
 
-    it('should remove azureClientSecret when empty string is provided in request', async() => {
+    it('should remove azureClientSecret when empty string is provided in request', async () => {
       const existingSecret = 'existing-azure-secret-12345';
       await setupAzureSecret(existingSecret);
 
-      expect(configManager.getConfig('azure:clientSecret')).toBe(existingSecret);
+      expect(configManager.getConfig('azure:clientSecret')).toBe(
+        existingSecret,
+      );
 
       const response = await request(app)
         .put('/')

+ 173 - 94
apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.ts

@@ -1,5 +1,7 @@
 import {
-  toNonBlankString, toNonBlankStringOrUndefined, SCOPE,
+  SCOPE,
+  toNonBlankString,
+  toNonBlankStringOrUndefined,
 } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import express from 'express';
@@ -14,7 +16,9 @@ import loggerFactory from '~/utils/logger';
 import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 
-const logger = loggerFactory('growi:routes:apiv3:app-settings:file-upload-setting');
+const logger = loggerFactory(
+  'growi:routes:apiv3:app-settings:file-upload-setting',
+);
 
 const router = express.Router();
 
@@ -46,7 +50,11 @@ type AzureResponseParams = BaseResponseParams & {
   azureReferenceFileWithRelayMode?: boolean;
 };
 
-type ResponseParams = BaseResponseParams | GcsResponseParams | AwsResponseParams | AzureResponseParams;
+type ResponseParams =
+  | BaseResponseParams
+  | GcsResponseParams
+  | AwsResponseParams
+  | AzureResponseParams;
 
 const validator = {
   fileUploadSetting: [
@@ -54,12 +62,14 @@ const validator = {
     body('gcsApiKeyJsonPath').optional(),
     body('gcsBucket').optional(),
     body('gcsUploadNamespace').optional(),
-    body('gcsReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
+    body('gcsReferenceFileWithRelayMode')
+      .if((value) => value != null)
+      .isBoolean(),
     body('s3Bucket').optional(),
     body('s3Region')
       .optional()
-      .if(value => value !== '' && value != null)
-      .custom(async(value) => {
+      .if((value) => value !== '' && value != null)
+      .custom(async (value) => {
         const { t } = await getTranslation();
         if (!/^[a-z]+-[a-z]+-\d+$/.test(value)) {
           throw new Error(t('validation.aws_region'));
@@ -68,23 +78,30 @@ const validator = {
       }),
     body('s3CustomEndpoint')
       .optional()
-      .if(value => value !== '' && value != null)
-      .custom(async(value) => {
+      .if((value) => value !== '' && value != null)
+      .custom(async (value) => {
         const { t } = await getTranslation();
         if (!/^(https?:\/\/[^/]+|)$/.test(value)) {
           throw new Error(t('validation.aws_custom_endpoint'));
         }
         return true;
       }),
-    body('s3AccessKeyId').optional().if(value => value !== '' && value != null).matches(/^[\da-zA-Z]+$/),
+    body('s3AccessKeyId')
+      .optional()
+      .if((value) => value !== '' && value != null)
+      .matches(/^[\da-zA-Z]+$/),
     body('s3SecretAccessKey').optional(),
-    body('s3ReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
+    body('s3ReferenceFileWithRelayMode')
+      .if((value) => value != null)
+      .isBoolean(),
     body('azureTenantId').optional(),
     body('azureClientId').optional(),
     body('azureClientSecret').optional(),
     body('azureStorageAccountName').optional(),
     body('azureStorageStorageName').optional(),
-    body('azureReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
+    body('azureReferenceFileWithRelayMode')
+      .if((value) => value != null)
+      .isBoolean(),
   ],
 };
 
@@ -118,24 +135,35 @@ const validator = {
  */
 /** @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);
   const addActivity = generateAddActivityMiddleware();
 
   const activityEvent = crowi.event('activity');
 
   //  eslint-disable-next-line max-len
-  router.put('/', accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
-    loginRequiredStrictly, adminRequired, addActivity, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
+  router.put(
+    '/',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.fileUploadSetting,
+    apiV3FormValidator,
+    async (req, res) => {
       const { fileUploadType } = req.body;
 
       if (fileUploadType === 'local' || fileUploadType === 'gridfs') {
         try {
-          await configManager.updateConfigs({
-            'app:fileUploadType': fileUploadType,
-          }, { skipPubsub: true });
-        }
-        catch (err) {
+          await configManager.updateConfigs(
+            {
+              'app:fileUploadType': fileUploadType,
+            },
+            { skipPubsub: true },
+          );
+        } catch (err) {
           const msg = `Error occurred in updating ${fileUploadType} settings: ${err.message}`;
           logger.error('Error', err);
           return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
@@ -146,55 +174,67 @@ module.exports = (crowi) => {
         try {
           try {
             toNonBlankString(req.body.s3Bucket);
-          }
-          catch (err) {
+          } catch (err) {
             throw new Error('S3 Bucket name is required');
           }
           try {
             toNonBlankString(req.body.s3Region);
-          }
-          catch (err) {
+          } catch (err) {
             throw new Error('S3 Region is required');
           }
-          await configManager.updateConfigs({
-            'app:fileUploadType': fileUploadType,
-            'aws:s3Region': toNonBlankString(req.body.s3Region),
-            'aws:s3Bucket': toNonBlankString(req.body.s3Bucket),
-            'aws:referenceFileWithRelayMode': req.body.s3ReferenceFileWithRelayMode,
-          },
-          { skipPubsub: true });
+          await configManager.updateConfigs(
+            {
+              'app:fileUploadType': fileUploadType,
+              'aws:s3Region': toNonBlankString(req.body.s3Region),
+              'aws:s3Bucket': toNonBlankString(req.body.s3Bucket),
+              'aws:referenceFileWithRelayMode':
+                req.body.s3ReferenceFileWithRelayMode,
+            },
+            { skipPubsub: true },
+          );
 
           // Update optional non-secret fields (can be removed if undefined)
-          await configManager.updateConfigs({
-            'aws:s3CustomEndpoint': toNonBlankStringOrUndefined(req.body.s3CustomEndpoint),
-          },
-          {
-            skipPubsub: true,
-            removeIfUndefined: true,
-          });
-
-          // Update secret fields only if explicitly provided in request
-          if (req.body.s3AccessKeyId !== undefined) {
-            await configManager.updateConfigs({
-              'aws:s3AccessKeyId': toNonBlankStringOrUndefined(req.body.s3AccessKeyId),
+          await configManager.updateConfigs(
+            {
+              'aws:s3CustomEndpoint': toNonBlankStringOrUndefined(
+                req.body.s3CustomEndpoint,
+              ),
             },
             {
               skipPubsub: true,
               removeIfUndefined: true,
-            });
+            },
+          );
+
+          // Update secret fields only if explicitly provided in request
+          if (req.body.s3AccessKeyId !== undefined) {
+            await configManager.updateConfigs(
+              {
+                'aws:s3AccessKeyId': toNonBlankStringOrUndefined(
+                  req.body.s3AccessKeyId,
+                ),
+              },
+              {
+                skipPubsub: true,
+                removeIfUndefined: true,
+              },
+            );
           }
 
           if (req.body.s3SecretAccessKey !== undefined) {
-            await configManager.updateConfigs({
-              'aws:s3SecretAccessKey': toNonBlankStringOrUndefined(req.body.s3SecretAccessKey),
-            },
-            {
-              skipPubsub: true,
-              removeIfUndefined: true,
-            });
+            await configManager.updateConfigs(
+              {
+                'aws:s3SecretAccessKey': toNonBlankStringOrUndefined(
+                  req.body.s3SecretAccessKey,
+                ),
+              },
+              {
+                skipPubsub: true,
+                removeIfUndefined: true,
+              },
+            );
           }
-        }
-        catch (err) {
+        } catch (err) {
           const msg = `Error occurred in updating AWS S3 settings: ${err.message}`;
           logger.error('Error', err);
           return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
@@ -203,28 +243,38 @@ module.exports = (crowi) => {
 
       if (fileUploadType === 'gcs') {
         try {
-          await configManager.updateConfigs({
-            'app:fileUploadType': fileUploadType,
-            'gcs:referenceFileWithRelayMode': req.body.gcsReferenceFileWithRelayMode,
-          },
-          { skipPubsub: true });
+          await configManager.updateConfigs(
+            {
+              'app:fileUploadType': fileUploadType,
+              'gcs:referenceFileWithRelayMode':
+                req.body.gcsReferenceFileWithRelayMode,
+            },
+            { skipPubsub: true },
+          );
 
           // Update optional non-secret fields (can be removed if undefined)
-          await configManager.updateConfigs({
-            'gcs:bucket': toNonBlankStringOrUndefined(req.body.gcsBucket),
-            'gcs:uploadNamespace': toNonBlankStringOrUndefined(req.body.gcsUploadNamespace),
-          },
-          { skipPubsub: true, removeIfUndefined: true });
+          await configManager.updateConfigs(
+            {
+              'gcs:bucket': toNonBlankStringOrUndefined(req.body.gcsBucket),
+              'gcs:uploadNamespace': toNonBlankStringOrUndefined(
+                req.body.gcsUploadNamespace,
+              ),
+            },
+            { skipPubsub: true, removeIfUndefined: true },
+          );
 
           // Update secret fields only if explicitly provided in request
           if (req.body.gcsApiKeyJsonPath !== undefined) {
-            await configManager.updateConfigs({
-              'gcs:apiKeyJsonPath': toNonBlankStringOrUndefined(req.body.gcsApiKeyJsonPath),
-            },
-            { skipPubsub: true, removeIfUndefined: true });
+            await configManager.updateConfigs(
+              {
+                'gcs:apiKeyJsonPath': toNonBlankStringOrUndefined(
+                  req.body.gcsApiKeyJsonPath,
+                ),
+              },
+              { skipPubsub: true, removeIfUndefined: true },
+            );
           }
-        }
-        catch (err) {
+        } catch (err) {
           const msg = `Error occurred in updating GCS settings: ${err.message}`;
           logger.error('Error', err);
           return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
@@ -233,28 +283,46 @@ module.exports = (crowi) => {
 
       if (fileUploadType === 'azure') {
         try {
-          await configManager.updateConfigs({
-            'app:fileUploadType': fileUploadType,
-            'azure:referenceFileWithRelayMode': req.body.azureReferenceFileWithRelayMode,
-          },
-          { skipPubsub: true });
+          await configManager.updateConfigs(
+            {
+              'app:fileUploadType': fileUploadType,
+              'azure:referenceFileWithRelayMode':
+                req.body.azureReferenceFileWithRelayMode,
+            },
+            { skipPubsub: true },
+          );
 
           // Update optional non-secret fields (can be removed if undefined)
-          await configManager.updateConfigs({
-            'azure:tenantId': toNonBlankStringOrUndefined(req.body.azureTenantId),
-            'azure:clientId': toNonBlankStringOrUndefined(req.body.azureClientId),
-            'azure:storageAccountName': toNonBlankStringOrUndefined(req.body.azureStorageAccountName),
-            'azure:storageContainerName': toNonBlankStringOrUndefined(req.body.azureStorageContainerName),
-          }, { skipPubsub: true, removeIfUndefined: true });
+          await configManager.updateConfigs(
+            {
+              'azure:tenantId': toNonBlankStringOrUndefined(
+                req.body.azureTenantId,
+              ),
+              'azure:clientId': toNonBlankStringOrUndefined(
+                req.body.azureClientId,
+              ),
+              'azure:storageAccountName': toNonBlankStringOrUndefined(
+                req.body.azureStorageAccountName,
+              ),
+              'azure:storageContainerName': toNonBlankStringOrUndefined(
+                req.body.azureStorageContainerName,
+              ),
+            },
+            { skipPubsub: true, removeIfUndefined: true },
+          );
 
           // Update secret fields only if explicitly provided in request
           if (req.body.azureClientSecret !== undefined) {
-            await configManager.updateConfigs({
-              'azure:clientSecret': toNonBlankStringOrUndefined(req.body.azureClientSecret),
-            }, { skipPubsub: true, removeIfUndefined: true });
+            await configManager.updateConfigs(
+              {
+                'azure:clientSecret': toNonBlankStringOrUndefined(
+                  req.body.azureClientSecret,
+                ),
+              },
+              { skipPubsub: true, removeIfUndefined: true },
+            );
           }
-        }
-        catch (err) {
+        } catch (err) {
           const msg = `Error occurred in updating Azure settings: ${err.message}`;
           logger.error('Error', err);
           return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
@@ -275,7 +343,9 @@ module.exports = (crowi) => {
             gcsApiKeyJsonPath: configManager.getConfig('gcs:apiKeyJsonPath'),
             gcsBucket: configManager.getConfig('gcs:bucket'),
             gcsUploadNamespace: configManager.getConfig('gcs:uploadNamespace'),
-            gcsReferenceFileWithRelayMode: configManager.getConfig('gcs:referenceFileWithRelayMode'),
+            gcsReferenceFileWithRelayMode: configManager.getConfig(
+              'gcs:referenceFileWithRelayMode',
+            ),
           };
         }
 
@@ -286,7 +356,9 @@ module.exports = (crowi) => {
             s3CustomEndpoint: configManager.getConfig('aws:s3CustomEndpoint'),
             s3Bucket: configManager.getConfig('aws:s3Bucket'),
             s3AccessKeyId: configManager.getConfig('aws:s3AccessKeyId'),
-            s3ReferenceFileWithRelayMode: configManager.getConfig('aws:referenceFileWithRelayMode'),
+            s3ReferenceFileWithRelayMode: configManager.getConfig(
+              'aws:referenceFileWithRelayMode',
+            ),
           };
         }
 
@@ -296,23 +368,30 @@ module.exports = (crowi) => {
             azureTenantId: configManager.getConfig('azure:tenantId'),
             azureClientId: configManager.getConfig('azure:clientId'),
             azureClientSecret: configManager.getConfig('azure:clientSecret'),
-            azureStorageAccountName: configManager.getConfig('azure:storageAccountName'),
-            azureStorageContainerName: configManager.getConfig('azure:storageContainerName'),
-            azureReferenceFileWithRelayMode: configManager.getConfig('azure:referenceFileWithRelayMode'),
+            azureStorageAccountName: configManager.getConfig(
+              'azure:storageAccountName',
+            ),
+            azureStorageContainerName: configManager.getConfig(
+              'azure:storageContainerName',
+            ),
+            azureReferenceFileWithRelayMode: configManager.getConfig(
+              'azure:referenceFileWithRelayMode',
+            ),
           };
         }
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ responseParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in retrieving file upload configurations';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
       }
-
-    });
+    },
+  );
 
   return router;
 };

+ 342 - 175
apps/app/src/server/routes/apiv3/app-settings/index.ts

@@ -1,6 +1,4 @@
-import {
-  ConfigSource, SCOPE,
-} from '@growi/core/dist/interfaces';
+import { ConfigSource, SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { body } from 'express-validator';
 
@@ -15,7 +13,6 @@ import loggerFactory from '~/utils/logger';
 import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 
-
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
 
 const { pathUtils } = require('@growi/core/dist/utils');
@@ -23,7 +20,6 @@ const express = require('express');
 
 const router = express.Router();
 
-
 /**
  * @swagger
  *
@@ -317,7 +313,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);
   const addActivity = generateAddActivityMiddleware();
 
@@ -333,29 +331,39 @@ module.exports = (crowi) => {
     ],
     siteUrlSetting: [
       // https://regex101.com/r/5Xef8V/1
-      body('siteUrl').trim().matches(/^(https?:\/\/)/).isURL({ require_tld: false }),
+      body('siteUrl')
+        .trim()
+        .matches(/^(https?:\/\/)/)
+        .isURL({ require_tld: false }),
     ],
     mailSetting: [
-      body('fromAddress').trim().if(value => value !== '').isEmail(),
+      body('fromAddress')
+        .trim()
+        .if((value) => value !== '')
+        .isEmail(),
       body('transmissionMethod').isIn(['smtp', 'ses']),
     ],
     smtpSetting: [
       body('smtpHost').trim(),
-      body('smtpPort').trim().if(value => value !== '').isPort(),
+      body('smtpPort')
+        .trim()
+        .if((value) => value !== '')
+        .isPort(),
       body('smtpUser').trim(),
       body('smtpPassword').trim(),
     ],
     sesSetting: [
-      body('sesAccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
+      body('sesAccessKeyId')
+        .trim()
+        .if((value) => value !== '')
+        .matches(/^[\da-zA-Z]+$/),
       body('sesSecretAccessKey').trim(),
     ],
     pageBulkExportSettings: [
       body('isBulkExportPagesEnabled').isBoolean(),
       body('bulkExportDownloadExpirationSeconds').isInt(),
     ],
-    maintenanceMode: [
-      body('flag').isBoolean(),
-    ],
+    maintenanceMode: [body('flag').isBoolean()],
   };
 
   /**
@@ -380,74 +388,141 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/AppSettingParams'
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.APP], { acceptLegacy: true }), loginRequiredStrictly, adminRequired, async(req, res) => {
-    const appSettingsParams = {
-      title: configManager.getConfig('app:title'),
-      confidential: configManager.getConfig('app:confidential'),
-      globalLang: configManager.getConfig('app:globalLang'),
-      isEmailPublishedForNewUser: configManager.getConfig('customize:isEmailPublishedForNewUser'),
-      fileUpload: configManager.getConfig('app:fileUpload'),
-      useOnlyEnvVarsForIsBulkExportPagesEnabled: configManager.getConfig('env:useOnlyEnvVars:app:isBulkExportPagesEnabled'),
-      isV5Compatible: configManager.getConfig('app:isV5Compatible'),
-      siteUrl: configManager.getConfig('app:siteUrl'),
-      siteUrlUseOnlyEnvVars: configManager.getConfig('env:useOnlyEnvVars:app:siteUrl'),
-      envSiteUrl: configManager.getConfig('app:siteUrl', ConfigSource.env),
-      isMailerSetup: crowi.mailService.isMailerSetup,
-      fromAddress: configManager.getConfig('mail:from'),
-
-      transmissionMethod: configManager.getConfig('mail:transmissionMethod'),
-      smtpHost: configManager.getConfig('mail:smtpHost'),
-      smtpPort: configManager.getConfig('mail:smtpPort'),
-      smtpUser: configManager.getConfig('mail:smtpUser'),
-      smtpPassword: configManager.getConfig('mail:smtpPassword'),
-      sesAccessKeyId: configManager.getConfig('mail:sesAccessKeyId'),
-      sesSecretAccessKey: configManager.getConfig('mail:sesSecretAccessKey'),
-
-      fileUploadType: configManager.getConfig('app:fileUploadType'),
-      envFileUploadType: configManager.getConfig('app:fileUploadType', ConfigSource.env),
-      useOnlyEnvVarForFileUploadType: configManager.getConfig('env:useOnlyEnvVars:app:fileUploadType'),
-
-      s3Region: configManager.getConfig('aws:s3Region'),
-      s3CustomEndpoint: configManager.getConfig('aws:s3CustomEndpoint'),
-      s3Bucket: configManager.getConfig('aws:s3Bucket'),
-      s3AccessKeyId: configManager.getConfig('aws:s3AccessKeyId'),
-      s3ReferenceFileWithRelayMode: configManager.getConfig('aws:referenceFileWithRelayMode'),
-
-      gcsUseOnlyEnvVars: configManager.getConfig('env:useOnlyEnvVars:gcs'),
-      gcsApiKeyJsonPath: configManager.getConfig('gcs:apiKeyJsonPath'),
-      gcsBucket: configManager.getConfig('gcs:bucket'),
-      gcsUploadNamespace: configManager.getConfig('gcs:uploadNamespace'),
-      gcsReferenceFileWithRelayMode: configManager.getConfig('gcs:referenceFileWithRelayMode'),
-
-      envGcsApiKeyJsonPath: configManager.getConfig('gcs:apiKeyJsonPath', ConfigSource.env),
-      envGcsBucket: configManager.getConfig('gcs:bucket', ConfigSource.env),
-      envGcsUploadNamespace: configManager.getConfig('gcs:uploadNamespace', ConfigSource.env),
-
-      azureUseOnlyEnvVars: configManager.getConfig('env:useOnlyEnvVars:azure'),
-      azureTenantId: configManager.getConfig('azure:tenantId', ConfigSource.db),
-      azureClientId: configManager.getConfig('azure:clientId', ConfigSource.db),
-      azureClientSecret: configManager.getConfig('azure:clientSecret', ConfigSource.db),
-      azureStorageAccountName: configManager.getConfig('azure:storageAccountName', ConfigSource.db),
-      azureStorageContainerName: configManager.getConfig('azure:storageContainerName', ConfigSource.db),
-      azureReferenceFileWithRelayMode: configManager.getConfig('azure:referenceFileWithRelayMode'),
-
-      envAzureTenantId: configManager.getConfig('azure:tenantId', ConfigSource.env),
-      envAzureClientId: configManager.getConfig('azure:clientId', ConfigSource.env),
-      envAzureClientSecret: configManager.getConfig('azure:clientSecret', ConfigSource.env),
-      envAzureStorageAccountName: configManager.getConfig('azure:storageAccountName', ConfigSource.env),
-      envAzureStorageContainerName: configManager.getConfig('azure:storageContainerName', ConfigSource.env),
-
-      isMaintenanceMode: configManager.getConfig('app:isMaintenanceMode'),
-
-      isBulkExportPagesEnabled: configManager.getConfig('app:isBulkExportPagesEnabled'),
-      envIsBulkExportPagesEnabled: configManager.getConfig('app:isBulkExportPagesEnabled'),
-      bulkExportDownloadExpirationSeconds: configManager.getConfig('app:bulkExportDownloadExpirationSeconds'),
-      // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
-      isBulkExportDisabledForCloud: configManager.getConfig('app:growiCloudUri') != null,
-    };
-    return res.apiv3({ appSettingsParams });
-
-  });
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.APP], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      const appSettingsParams = {
+        title: configManager.getConfig('app:title'),
+        confidential: configManager.getConfig('app:confidential'),
+        globalLang: configManager.getConfig('app:globalLang'),
+        isEmailPublishedForNewUser: configManager.getConfig(
+          'customize:isEmailPublishedForNewUser',
+        ),
+        fileUpload: configManager.getConfig('app:fileUpload'),
+        useOnlyEnvVarsForIsBulkExportPagesEnabled: configManager.getConfig(
+          'env:useOnlyEnvVars:app:isBulkExportPagesEnabled',
+        ),
+        isV5Compatible: configManager.getConfig('app:isV5Compatible'),
+        siteUrl: configManager.getConfig('app:siteUrl'),
+        siteUrlUseOnlyEnvVars: configManager.getConfig(
+          'env:useOnlyEnvVars:app:siteUrl',
+        ),
+        envSiteUrl: configManager.getConfig('app:siteUrl', ConfigSource.env),
+        isMailerSetup: crowi.mailService.isMailerSetup,
+        fromAddress: configManager.getConfig('mail:from'),
+
+        transmissionMethod: configManager.getConfig('mail:transmissionMethod'),
+        smtpHost: configManager.getConfig('mail:smtpHost'),
+        smtpPort: configManager.getConfig('mail:smtpPort'),
+        smtpUser: configManager.getConfig('mail:smtpUser'),
+        smtpPassword: configManager.getConfig('mail:smtpPassword'),
+        sesAccessKeyId: configManager.getConfig('mail:sesAccessKeyId'),
+        sesSecretAccessKey: configManager.getConfig('mail:sesSecretAccessKey'),
+
+        fileUploadType: configManager.getConfig('app:fileUploadType'),
+        envFileUploadType: configManager.getConfig(
+          'app:fileUploadType',
+          ConfigSource.env,
+        ),
+        useOnlyEnvVarForFileUploadType: configManager.getConfig(
+          'env:useOnlyEnvVars:app:fileUploadType',
+        ),
+
+        s3Region: configManager.getConfig('aws:s3Region'),
+        s3CustomEndpoint: configManager.getConfig('aws:s3CustomEndpoint'),
+        s3Bucket: configManager.getConfig('aws:s3Bucket'),
+        s3AccessKeyId: configManager.getConfig('aws:s3AccessKeyId'),
+        s3ReferenceFileWithRelayMode: configManager.getConfig(
+          'aws:referenceFileWithRelayMode',
+        ),
+
+        gcsUseOnlyEnvVars: configManager.getConfig('env:useOnlyEnvVars:gcs'),
+        gcsApiKeyJsonPath: configManager.getConfig('gcs:apiKeyJsonPath'),
+        gcsBucket: configManager.getConfig('gcs:bucket'),
+        gcsUploadNamespace: configManager.getConfig('gcs:uploadNamespace'),
+        gcsReferenceFileWithRelayMode: configManager.getConfig(
+          'gcs:referenceFileWithRelayMode',
+        ),
+
+        envGcsApiKeyJsonPath: configManager.getConfig(
+          'gcs:apiKeyJsonPath',
+          ConfigSource.env,
+        ),
+        envGcsBucket: configManager.getConfig('gcs:bucket', ConfigSource.env),
+        envGcsUploadNamespace: configManager.getConfig(
+          'gcs:uploadNamespace',
+          ConfigSource.env,
+        ),
+
+        azureUseOnlyEnvVars: configManager.getConfig(
+          'env:useOnlyEnvVars:azure',
+        ),
+        azureTenantId: configManager.getConfig(
+          'azure:tenantId',
+          ConfigSource.db,
+        ),
+        azureClientId: configManager.getConfig(
+          'azure:clientId',
+          ConfigSource.db,
+        ),
+        azureClientSecret: configManager.getConfig(
+          'azure:clientSecret',
+          ConfigSource.db,
+        ),
+        azureStorageAccountName: configManager.getConfig(
+          'azure:storageAccountName',
+          ConfigSource.db,
+        ),
+        azureStorageContainerName: configManager.getConfig(
+          'azure:storageContainerName',
+          ConfigSource.db,
+        ),
+        azureReferenceFileWithRelayMode: configManager.getConfig(
+          'azure:referenceFileWithRelayMode',
+        ),
+
+        envAzureTenantId: configManager.getConfig(
+          'azure:tenantId',
+          ConfigSource.env,
+        ),
+        envAzureClientId: configManager.getConfig(
+          'azure:clientId',
+          ConfigSource.env,
+        ),
+        envAzureClientSecret: configManager.getConfig(
+          'azure:clientSecret',
+          ConfigSource.env,
+        ),
+        envAzureStorageAccountName: configManager.getConfig(
+          'azure:storageAccountName',
+          ConfigSource.env,
+        ),
+        envAzureStorageContainerName: configManager.getConfig(
+          'azure:storageContainerName',
+          ConfigSource.env,
+        ),
+
+        isMaintenanceMode: configManager.getConfig('app:isMaintenanceMode'),
+
+        isBulkExportPagesEnabled: configManager.getConfig(
+          'app:isBulkExportPagesEnabled',
+        ),
+        envIsBulkExportPagesEnabled: configManager.getConfig(
+          'app:isBulkExportPagesEnabled',
+        ),
+        bulkExportDownloadExpirationSeconds: configManager.getConfig(
+          'app:bulkExportDownloadExpirationSeconds',
+        ),
+        // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
+        isBulkExportDisabledForCloud:
+          configManager.getConfig('app:growiCloudUri') != null,
+      };
+      return res.apiv3({ appSettingsParams });
+    },
+  );
 
   /**
    * @swagger
@@ -477,14 +552,21 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/AppSettingPutParams'
    */
-  router.put('/app-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.appSetting, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/app-setting',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.appSetting,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestAppSettingParams = {
         'app:title': req.body.title,
         'app:confidential': req.body.confidential,
         'app:globalLang': req.body.globalLang,
-        'customize:isEmailPublishedForNewUser': req.body.isEmailPublishedForNewUser,
+        'customize:isEmailPublishedForNewUser':
+          req.body.isEmailPublishedForNewUser,
         'app:fileUpload': req.body.fileUpload,
       };
 
@@ -494,22 +576,25 @@ module.exports = (crowi) => {
           title: configManager.getConfig('app:title'),
           confidential: configManager.getConfig('app:confidential'),
           globalLang: configManager.getConfig('app:globalLang'),
-          isEmailPublishedForNewUser: configManager.getConfig('customize:isEmailPublishedForNewUser'),
+          isEmailPublishedForNewUser: configManager.getConfig(
+            'customize:isEmailPublishedForNewUser',
+          ),
           fileUpload: configManager.getConfig('app:fileUpload'),
         };
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_APP_SETTINGS_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_APP_SETTINGS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ appSettingParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating app setting';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-appSetting-failed'));
       }
-
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -543,14 +628,24 @@ module.exports = (crowi) => {
    *                          description: Site URL. e.g. https://example.com, https://example.com:3000
    *                          example: 'http://localhost:3000'
    */
-  router.put('/site-url-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.siteUrlSetting, apiV3FormValidator,
-    async(req, res) => {
-      const useOnlyEnvVars = configManager.getConfig('env:useOnlyEnvVars:app:siteUrl');
+  router.put(
+    '/site-url-setting',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.siteUrlSetting,
+    apiV3FormValidator,
+    async (req, res) => {
+      const useOnlyEnvVars = configManager.getConfig(
+        'env:useOnlyEnvVars:app:siteUrl',
+      );
 
       if (useOnlyEnvVars) {
         const msg = 'Updating the Site URL is prohibited on this system.';
-        return res.apiv3Err(new ErrorV3(msg, 'update-siteUrlSetting-prohibited'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'update-siteUrlSetting-prohibited'),
+        );
       }
 
       const requestSiteUrlSettingParams = {
@@ -563,17 +658,18 @@ module.exports = (crowi) => {
           siteUrl: configManager.getConfig('app:siteUrl'),
         };
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_SITE_URL_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_SITE_URL_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ siteUrlSettingParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating site url setting';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-siteUrlSetting-failed'));
       }
-
-    });
+    },
+  );
 
   /**
    * send mail (Promise wrapper)
@@ -583,8 +679,7 @@ module.exports = (crowi) => {
       smtpClient.sendMail(options, (err, res) => {
         if (err) {
           reject(err);
-        }
-        else {
+        } else {
           resolve(res);
         }
       });
@@ -595,7 +690,6 @@ module.exports = (crowi) => {
    * validate mail setting send test mail
    */
   async function sendTestEmail(destinationAddress) {
-
     const { mailService } = crowi;
 
     if (!mailService.isMailerSetup) {
@@ -645,13 +739,13 @@ module.exports = (crowi) => {
     await sendMailPromiseWrapper(smtpClient, mailOptions);
   }
 
-  const updateMailSettinConfig = async function(requestMailSettingParams) {
-    const {
-      mailService,
-    } = crowi;
+  const updateMailSettinConfig = async (requestMailSettingParams) => {
+    const { mailService } = crowi;
 
     // update config without publishing S2sMessage
-    await configManager.updateConfigs(requestMailSettingParams, { skipPubsub: true });
+    await configManager.updateConfigs(requestMailSettingParams, {
+      skipPubsub: true,
+    });
 
     await mailService.initialize();
     mailService.publishUpdatedMessage();
@@ -696,9 +790,15 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/SmtpSettingResponseParams'
    */
-  router.put('/smtp-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.smtpSetting, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/smtp-setting',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.smtpSetting,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestMailSettingParams = {
         'mail:from': req.body.fromAddress,
         'mail:transmissionMethod': req.body.transmissionMethod,
@@ -709,17 +809,21 @@ module.exports = (crowi) => {
       };
 
       try {
-        const mailSettingParams = await updateMailSettinConfig(requestMailSettingParams);
-        const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_SMTP_UPDATE };
+        const mailSettingParams = await updateMailSettinConfig(
+          requestMailSettingParams,
+        );
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_MAIL_SMTP_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
         return res.apiv3({ mailSettingParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating smtp setting';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-smtpSetting-failed'));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -740,22 +844,30 @@ module.exports = (crowi) => {
    *                  type: object
    *                  description: Empty object
    */
-  router.post('/smtp-test', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
-    const { t } = await getTranslation({ lang: req.user.lang });
+  router.post(
+    '/smtp-test',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    async (req, res) => {
+      const { t } = await getTranslation({ lang: req.user.lang });
 
-    try {
-      await sendTestEmail(req.user.email);
-      const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_TEST_SUBMIT };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({});
-    }
-    catch (err) {
-      const msg = t('validation.failed_to_send_a_test_email');
-      logger.error('Error', err);
-      logger.debug('Error validate mail setting: ', err);
-      return res.apiv3Err(new ErrorV3(msg, 'send-email-with-smtp-failed'));
-    }
-  });
+      try {
+        await sendTestEmail(req.user.email);
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_MAIL_TEST_SUBMIT,
+        };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({});
+      } catch (err) {
+        const msg = t('validation.failed_to_send_a_test_email');
+        logger.error('Error', err);
+        logger.debug('Error validate mail setting: ', err);
+        return res.apiv3Err(new ErrorV3(msg, 'send-email-with-smtp-failed'));
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -781,9 +893,15 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/SesSettingResponseParams'
    */
-  router.put('/ses-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity,
-    validator.sesSetting, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/ses-setting',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.sesSetting,
+    apiV3FormValidator,
+    async (req, res) => {
       const { mailService } = crowi;
 
       const requestSesSettingParams = {
@@ -793,11 +911,12 @@ module.exports = (crowi) => {
         'mail:sesSecretAccessKey': req.body.sesSecretAccessKey,
       };
 
-      let mailSettingParams;
+      let mailSettingParams: Awaited<ReturnType<typeof updateMailSettinConfig>>;
       try {
-        mailSettingParams = await updateMailSettinConfig(requestSesSettingParams);
-      }
-      catch (err) {
+        mailSettingParams = await updateMailSettinConfig(
+          requestSesSettingParams,
+        );
+      } catch (err) {
         const msg = 'Error occurred in updating ses setting';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-ses-setting-failed'));
@@ -805,41 +924,57 @@ module.exports = (crowi) => {
 
       await mailService.initialize();
       mailService.publishUpdatedMessage();
-      const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_SES_UPDATE };
+      const parameters = {
+        action: SupportedAction.ACTION_ADMIN_MAIL_SES_UPDATE,
+      };
       activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ mailSettingParams });
-    });
+    },
+  );
 
   router.use('/file-upload-setting', require('./file-upload-setting')(crowi));
 
-
-  router.put('/page-bulk-export-settings',
-    accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity, validator.pageBulkExportSettings, apiV3FormValidator,
-    async(req, res) => {
+  router.put(
+    '/page-bulk-export-settings',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.pageBulkExportSettings,
+    apiV3FormValidator,
+    async (req, res) => {
       const requestParams = {
         'app:isBulkExportPagesEnabled': req.body.isBulkExportPagesEnabled,
-        'app:bulkExportDownloadExpirationSeconds': req.body.bulkExportDownloadExpirationSeconds,
+        'app:bulkExportDownloadExpirationSeconds':
+          req.body.bulkExportDownloadExpirationSeconds,
       };
 
       try {
         await configManager.updateConfigs(requestParams, { skipPubsub: true });
         const responseParams = {
-          isBulkExportPagesEnabled: configManager.getConfig('app:isBulkExportPagesEnabled'),
-          bulkExportDownloadExpirationSeconds: configManager.getConfig('app:bulkExportDownloadExpirationSeconds'),
+          isBulkExportPagesEnabled: configManager.getConfig(
+            'app:isBulkExportPagesEnabled',
+          ),
+          bulkExportDownloadExpirationSeconds: configManager.getConfig(
+            'app:bulkExportDownloadExpirationSeconds',
+          ),
         };
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_APP_SETTINGS_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_APP_SETTINGS_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ responseParams });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating page bulk export settings';
         logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'update-page-bulk-export-settings-failed'));
+        return res.apiv3Err(
+          new ErrorV3(msg, 'update-page-bulk-export-settings-failed'),
+        );
       }
-
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -865,27 +1000,39 @@ module.exports = (crowi) => {
    *                      description: is V5 compatible, or not
    *                      example: true
    */
-  router.post('/v5-schema-migration',
-    accessTokenParser([SCOPE.WRITE.ADMIN.APP], { acceptLegacy: true }), loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.post(
+    '/v5-schema-migration',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
       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'));
+        return res.apiv3Err(
+          new ErrorV3(
+            'GROWI is not maintenance mode. To import data, please activate the maintenance mode first.',
+            'not_maintenance_mode',
+          ),
+        );
       }
 
       const isV5Compatible = configManager.getConfig('app:isV5Compatible');
 
       try {
         if (!isV5Compatible) {
-        // This method throws and emit socketIo event when error occurs
+          // This method throws and emit socketIo event when error occurs
           crowi.pageService.normalizeAllPublicPages();
         }
-      }
-      catch (err) {
-        return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
+      } catch (err) {
+        return res.apiv3Err(
+          new ErrorV3(`Failed to migrate pages: ${err.message}`),
+          500,
+        );
       }
 
       return res.apiv3({ isV5Compatible });
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -920,28 +1067,47 @@ module.exports = (crowi) => {
    *                      description: true if maintenance mode is enabled
    *                      example: true
    */
-  router.post('/maintenance-mode',
+  router.post(
+    '/maintenance-mode',
     accessTokenParser([SCOPE.WRITE.ADMIN.APP], { acceptLegacy: true }),
-    loginRequiredStrictly, adminRequired, addActivity, validator.maintenanceMode, apiV3FormValidator, async(req, res) => {
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.maintenanceMode,
+    apiV3FormValidator,
+    async (req, res) => {
       const { flag } = req.body;
       const parameters = {};
       try {
         if (flag) {
           await crowi.appService.startMaintenanceMode();
-          Object.assign(parameters, { action: SupportedAction.ACTION_ADMIN_MAINTENANCEMODE_ENABLED });
-        }
-        else {
+          Object.assign(parameters, {
+            action: SupportedAction.ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
+          });
+        } else {
           await crowi.appService.endMaintenanceMode();
-          Object.assign(parameters, { action: SupportedAction.ACTION_ADMIN_MAINTENANCEMODE_DISABLED });
+          Object.assign(parameters, {
+            action: SupportedAction.ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
+          });
         }
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         if (flag) {
-          res.apiv3Err(new ErrorV3('Failed to start maintenance mode', 'failed_to_start_maintenance_mode'), 500);
-        }
-        else {
-          res.apiv3Err(new ErrorV3('Failed to end maintenance mode', 'failed_to_end_maintenance_mode'), 500);
+          res.apiv3Err(
+            new ErrorV3(
+              'Failed to start maintenance mode',
+              'failed_to_start_maintenance_mode',
+            ),
+            500,
+          );
+        } else {
+          res.apiv3Err(
+            new ErrorV3(
+              'Failed to end maintenance mode',
+              'failed_to_end_maintenance_mode',
+            ),
+            500,
+          );
         }
       }
 
@@ -950,7 +1116,8 @@ module.exports = (crowi) => {
       }
 
       res.apiv3({ flag });
-    });
+    },
+  );
 
   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;
 };

+ 16 - 11
apps/app/src/server/routes/apiv3/page/check-page-existence.ts

@@ -1,4 +1,5 @@
 import type { IPage, IUserHasId } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import type { Request, RequestHandler } from 'express';
@@ -6,7 +7,6 @@ import type { ValidationChain } from 'express-validator';
 import { query } from 'express-validator';
 import mongoose from 'mongoose';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
@@ -15,24 +15,27 @@ import loggerFactory from '~/utils/logger';
 
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
-
 const logger = loggerFactory('growi:routes:apiv3:page:check-page-existence');
 
-
 type ReqQuery = {
-  path: string,
-}
+  path: string;
+};
 
 interface Req extends Request<ReqQuery, ApiV3Response> {
-  user: IUserHasId,
+  user: IUserHasId;
 }
 
 type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
 
-export const checkPageExistenceHandlersFactory: CreatePageHandlersFactory = (crowi) => {
+export const checkPageExistenceHandlersFactory: CreatePageHandlersFactory = (
+  crowi,
+) => {
   const Page = mongoose.model<IPage, PageModel>('Page');
 
-  const loginRequired = require('../../../middlewares/login-required')(crowi, true);
+  const loginRequired = require('../../../middlewares/login-required')(
+    crowi,
+    true,
+  );
 
   // define validators for req.body
   const validator: ValidationChain[] = [
@@ -40,9 +43,11 @@ export const checkPageExistenceHandlersFactory: CreatePageHandlersFactory = (cro
   ];
 
   return [
-    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequired,
-    validator, apiV3FormValidator,
-    async(req: Req, res: ApiV3Response) => {
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired,
+    validator,
+    apiV3FormValidator,
+    async (req: Req, res: ApiV3Response) => {
       const { path } = req.query;
 
       if (path == null || Array.isArray(path)) {

+ 154 - 65
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -1,10 +1,16 @@
 import { allOrigin } from '@growi/core';
-import type {
-  IPage, IUser, IUserHasId,
-} from '@growi/core/dist/interfaces';
+import type { IPage, IUser, IUserHasId } from '@growi/core/dist/interfaces';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
-import { isCreatablePage, isUserPage, isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
-import { attachTitleHeader, normalizePath } from '@growi/core/dist/utils/path-utils';
+import {
+  isCreatablePage,
+  isUserPage,
+  isUsersHomepage,
+} from '@growi/core/dist/utils/page-path-utils';
+import {
+  attachTitleHeader,
+  normalizePath,
+} from '@growi/core/dist/utils/path-utils';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
@@ -16,14 +22,16 @@ import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import type { IOptionsForCreate } from '~/interfaces/page';
-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';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import PageTagRelation from '~/server/models/page-tag-relation';
-import { serializePageSecurely, serializeRevisionSecurely } from '~/server/models/serializers';
+import {
+  serializePageSecurely,
+  serializeRevisionSecurely,
+} from '~/server/models/serializers';
 import { configManager } from '~/server/service/config-manager';
 import { getTranslation } from '~/server/service/i18next';
 import loggerFactory from '~/utils/logger';
@@ -32,21 +40,29 @@ import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
-
 const logger = loggerFactory('growi:routes:apiv3:page:create-page');
 
-
-async function generateUntitledPath(parentPath: string, basePathname: string, index = 1): Promise<string> {
+async function generateUntitledPath(
+  parentPath: string,
+  basePathname: string,
+  index = 1,
+): Promise<string> {
   const Page = mongoose.model<IPage>('Page');
 
-  const path = normalizePath(`${normalizePath(parentPath)}/${basePathname}-${index}`);
-  if (await Page.exists({ path, isEmpty: false }) != null) {
+  const path = normalizePath(
+    `${normalizePath(parentPath)}/${basePathname}-${index}`,
+  );
+  if ((await Page.exists({ path, isEmpty: false })) != null) {
     return generateUntitledPath(parentPath, basePathname, index + 1);
   }
   return path;
 }
 
-async function determinePath(_parentPath?: string, _path?: string, optionalParentPath?: string): Promise<string> {
+async function determinePath(
+  _parentPath?: string,
+  _path?: string,
+  optionalParentPath?: string,
+): Promise<string> {
   const { t } = await getTranslation();
 
   const basePathname = t?.('create_page.untitled') || 'Untitled';
@@ -90,53 +106,85 @@ async function determinePath(_parentPath?: string, _path?: string, optionalParen
   return generateUntitledPath('/', basePathname);
 }
 
-
-type ReqBody = IApiv3PageCreateParams
+type ReqBody = IApiv3PageCreateParams;
 
 interface CreatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
-  user: IUserHasId,
+  user: IUserHasId;
 }
 
 type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
 
 export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
   const Page = mongoose.model<IPage, PageModel>('Page');
-  const User = mongoose.model<IUser, { isExistUserByUserPagePath: any }>('User');
-
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const User = mongoose.model<IUser, { isExistUserByUserPagePath: any }>(
+    'User',
+  );
 
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(
+    crowi,
+  );
 
   // define validators for req.body
   const validator: ValidationChain[] = [
-    body('path').optional().not().isEmpty({ ignore_whitespace: true })
+    body('path')
+      .optional()
+      .not()
+      .isEmpty({ ignore_whitespace: true })
       .withMessage("Empty value is not allowed for 'path'"),
-    body('parentPath').optional().not().isEmpty({ ignore_whitespace: true })
+    body('parentPath')
+      .optional()
+      .not()
+      .isEmpty({ ignore_whitespace: true })
       .withMessage("Empty value is not allowed for 'parentPath'"),
-    body('optionalParentPath').optional().not().isEmpty({ ignore_whitespace: true })
+    body('optionalParentPath')
+      .optional()
+      .not()
+      .isEmpty({ ignore_whitespace: true })
       .withMessage("Empty value is not allowed for 'optionalParentPath'"),
-    body('body').optional().isString()
+    body('body')
+      .optional()
+      .isString()
       .withMessage('body must be string or undefined'),
-    body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
-    body('onlyInheritUserRelatedGrantedGroups').optional().isBoolean().withMessage('onlyInheritUserRelatedGrantedGroups must be boolean'),
-    body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
+    body('grant')
+      .optional()
+      .isInt({ min: 0, max: 5 })
+      .withMessage('grant must be integer from 1 to 5'),
+    body('onlyInheritUserRelatedGrantedGroups')
+      .optional()
+      .isBoolean()
+      .withMessage('onlyInheritUserRelatedGrantedGroups must be boolean'),
+    body('overwriteScopesOfDescendants')
+      .optional()
+      .isBoolean()
+      .withMessage('overwriteScopesOfDescendants must be boolean'),
     body('pageTags').optional().isArray().withMessage('pageTags must be array'),
-    body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
-    body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
+    body('isSlackEnabled')
+      .optional()
+      .isBoolean()
+      .withMessage('isSlackEnabled must be boolean'),
+    body('slackChannels')
+      .optional()
+      .isString()
+      .withMessage('slackChannels must be string'),
     body('wip').optional().isBoolean().withMessage('wip must be boolean'),
-    body('origin').optional().isIn(allOrigin).withMessage('origin must be "view" or "editor"'),
+    body('origin')
+      .optional()
+      .isIn(allOrigin)
+      .withMessage('origin must be "view" or "editor"'),
   ];
 
-
   async function determineBodyAndTags(
-      path: string,
-      _body: string | null | undefined, _tags: string[] | null | undefined,
-  ): Promise<{ body: string, tags: string[] }> {
-
+    path: string,
+    _body: string | null | undefined,
+    _tags: string[] | null | undefined,
+  ): Promise<{ body: string; tags: string[] }> {
     let body: string = _body ?? '';
     let tags: string[] = _tags ?? [];
 
     if (_body == null) {
-      const isEnabledAttachTitleHeader = await configManager.getConfig('customize:isEnabledAttachTitleHeader');
+      const isEnabledAttachTitleHeader = await configManager.getConfig(
+        'customize:isEnabledAttachTitleHeader',
+      );
       if (isEnabledAttachTitleHeader) {
         body += `${attachTitleHeader(path)}\n`;
       }
@@ -153,14 +201,24 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
     return { body, tags };
   }
 
-  async function saveTags({ createdPage, pageTags }: { createdPage: PageDocument, pageTags: string[] }) {
+  async function saveTags({
+    createdPage,
+    pageTags,
+  }: {
+    createdPage: PageDocument;
+    pageTags: string[];
+  }) {
     const tagEvent = crowi.event('tag');
     await PageTagRelation.updatePageTags(createdPage.id, pageTags);
     tagEvent.emit('update', createdPage, pageTags);
     return PageTagRelation.listTagNamesByPage(createdPage.id);
   }
 
-  async function postAction(req: CreatePageRequest, res: ApiV3Response, createdPage: HydratedDocument<PageDocument>) {
+  async function postAction(
+    req: CreatePageRequest,
+    res: ApiV3Response,
+    createdPage: HydratedDocument<PageDocument>,
+  ) {
     // persist activity
     const parameters = {
       targetModel: SupportedTargetModel.MODEL_PAGE,
@@ -172,9 +230,12 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
 
     // global notification
     try {
-      await crowi.globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_CREATE, createdPage, req.user);
-    }
-    catch (err) {
+      await crowi.globalNotificationService.fire(
+        GlobalNotificationSettingEvent.PAGE_CREATE,
+        createdPage,
+        req.user,
+      );
+    } catch (err) {
       logger.error('Create grobal notification failed', err);
     }
 
@@ -182,34 +243,42 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
     const { isSlackEnabled, slackChannels } = req.body;
     if (isSlackEnabled) {
       try {
-        const results = await crowi.userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
+        const results = await crowi.userNotificationService.fire(
+          createdPage,
+          req.user,
+          slackChannels,
+          'create',
+        );
         results.forEach((result) => {
           if (result.status === 'rejected') {
             logger.error('Create user notification failed', result.reason);
           }
         });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Create user notification failed', err);
       }
     }
 
     // create subscription
     try {
-      await crowi.inAppNotificationService.createSubscription(req.user._id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
-    }
-    catch (err) {
+      await crowi.inAppNotificationService.createSubscription(
+        req.user._id,
+        createdPage._id,
+        subscribeRuleNames.PAGE_CREATE,
+      );
+    } catch (err) {
       logger.error('Failed to create subscription document', err);
     }
 
     // Rebuild vector store file
     if (isAiEnabled()) {
-      const { getOpenaiService } = await import('~/features/openai/server/services/openai');
+      const { getOpenaiService } = await import(
+        '~/features/openai/server/services/openai'
+      );
       try {
         const openaiService = getOpenaiService();
         await openaiService?.createVectorStoreFileOnPageCreate([createdPage]);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Rebuild vector store failed', err);
       }
     }
@@ -218,39 +287,60 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
   const addActivity = generateAddActivityMiddleware();
 
   return [
-    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly, excludeReadOnlyUser, addActivity,
-    validator, apiV3FormValidator,
-    async(req: CreatePageRequest, res: ApiV3Response) => {
-      const {
-        body: bodyByParam, pageTags: tagsByParam,
-      } = req.body;
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    addActivity,
+    validator,
+    apiV3FormValidator,
+    async (req: CreatePageRequest, res: ApiV3Response) => {
+      const { body: bodyByParam, pageTags: tagsByParam } = req.body;
 
       let pathToCreate: string;
       try {
         const { path, parentPath, optionalParentPath } = req.body;
-        pathToCreate = await determinePath(parentPath, path, optionalParentPath);
-      }
-      catch (err) {
-        return res.apiv3Err(new ErrorV3(err.toString(), 'could_not_create_page'));
+        pathToCreate = await determinePath(
+          parentPath,
+          path,
+          optionalParentPath,
+        );
+      } catch (err) {
+        return res.apiv3Err(
+          new ErrorV3(err.toString(), 'could_not_create_page'),
+        );
       }
 
       if (isUserPage(pathToCreate)) {
         const isExistUser = await User.isExistUserByUserPagePath(pathToCreate);
         if (!isExistUser) {
-          return res.apiv3Err("Unable to create a page under a non-existent user's user page");
+          return res.apiv3Err(
+            "Unable to create a page under a non-existent user's user page",
+          );
         }
       }
 
-      const { body, tags } = await determineBodyAndTags(pathToCreate, bodyByParam, tagsByParam);
+      const { body, tags } = await determineBodyAndTags(
+        pathToCreate,
+        bodyByParam,
+        tagsByParam,
+      );
 
       let createdPage: HydratedDocument<PageDocument>;
       try {
         const {
-          grant, grantUserGroupIds, onlyInheritUserRelatedGrantedGroups, overwriteScopesOfDescendants, wip, origin,
+          grant,
+          grantUserGroupIds,
+          onlyInheritUserRelatedGrantedGroups,
+          overwriteScopesOfDescendants,
+          wip,
+          origin,
         } = req.body;
 
         const options: IOptionsForCreate = {
-          onlyInheritUserRelatedGrantedGroups, overwriteScopesOfDescendants, wip, origin,
+          onlyInheritUserRelatedGrantedGroups,
+          overwriteScopesOfDescendants,
+          wip,
+          origin,
         };
         if (grant != null) {
           options.grant = grant;
@@ -262,8 +352,7 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
           req.user,
           options,
         );
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Error occurred while creating a page.', err);
         return res.apiv3Err(err);
       }

+ 71 - 50
apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts

@@ -1,10 +1,10 @@
 import type { IPage, IUserHasId } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { query } from 'express-validator';
 import mongoose from 'mongoose';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { PageModel } from '~/server/models/page';
@@ -13,65 +13,86 @@ 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:page:get-pages-by-page-paths');
 
-type GetPagePathsWithDescendantCountFactory = (crowi: Crowi) => RequestHandler[];
+type GetPagePathsWithDescendantCountFactory = (
+  crowi: Crowi,
+) => RequestHandler[];
 
 type ReqQuery = {
-  paths: string[],
-  userGroups?: string[],
-  isIncludeEmpty?: boolean,
-  includeAnyoneWithTheLink?: boolean,
-}
+  paths: string[];
+  userGroups?: string[];
+  isIncludeEmpty?: boolean;
+  includeAnyoneWithTheLink?: boolean;
+};
 
 interface Req extends Request<undefined, ApiV3Response, undefined, ReqQuery> {
-  user: IUserHasId,
+  user: IUserHasId;
 }
-export const getPagePathsWithDescendantCountFactory: GetPagePathsWithDescendantCountFactory = (crowi) => {
-  const Page = mongoose.model<IPage, PageModel>('Page');
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+export const getPagePathsWithDescendantCountFactory: GetPagePathsWithDescendantCountFactory =
+  (crowi) => {
+    const Page = mongoose.model<IPage, PageModel>('Page');
+    const loginRequiredStrictly =
+      require('../../../middlewares/login-required')(crowi);
 
-  const validator: ValidationChain[] = [
-    query('paths').isArray().withMessage('paths must be an array of strings'),
-    query('paths').custom((paths: string[]) => {
-      if (paths.length > 300) {
-        throw new Error('paths must be an array of strings with a maximum length of 300');
-      }
-      return true;
-    }),
-    query('paths.*') // each item of paths
-      .isString()
-      .withMessage('paths must be an array of strings'),
+    const validator: ValidationChain[] = [
+      query('paths').isArray().withMessage('paths must be an array of strings'),
+      query('paths').custom((paths: string[]) => {
+        if (paths.length > 300) {
+          throw new Error(
+            'paths must be an array of strings with a maximum length of 300',
+          );
+        }
+        return true;
+      }),
+      query('paths.*') // each item of paths
+        .isString()
+        .withMessage('paths must be an array of strings'),
 
-    query('userGroups').optional().isArray().withMessage('userGroups must be an array of strings'),
-    query('userGroups.*') // each item of userGroups
-      .isMongoId()
-      .withMessage('userGroups must be an array of strings'),
+      query('userGroups')
+        .optional()
+        .isArray()
+        .withMessage('userGroups must be an array of strings'),
+      query('userGroups.*') // each item of userGroups
+        .isMongoId()
+        .withMessage('userGroups must be an array of strings'),
 
-    query('isIncludeEmpty').optional().isBoolean().withMessage('isIncludeEmpty must be a boolean'),
-    query('isIncludeEmpty').toBoolean(),
+      query('isIncludeEmpty')
+        .optional()
+        .isBoolean()
+        .withMessage('isIncludeEmpty must be a boolean'),
+      query('isIncludeEmpty').toBoolean(),
 
-    query('includeAnyoneWithTheLink').optional().isBoolean().withMessage('includeAnyoneWithTheLink must be a boolean'),
-    query('includeAnyoneWithTheLink').toBoolean(),
-  ];
+      query('includeAnyoneWithTheLink')
+        .optional()
+        .isBoolean()
+        .withMessage('includeAnyoneWithTheLink must be a boolean'),
+      query('includeAnyoneWithTheLink').toBoolean(),
+    ];
 
-  return [
-    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly,
-    validator, apiV3FormValidator,
-    async(req: Req, res: ApiV3Response) => {
-      const {
-        paths, userGroups, isIncludeEmpty, includeAnyoneWithTheLink,
-      } = req.query;
+    return [
+      accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+      loginRequiredStrictly,
+      validator,
+      apiV3FormValidator,
+      async (req: Req, res: ApiV3Response) => {
+        const { paths, userGroups, isIncludeEmpty, includeAnyoneWithTheLink } =
+          req.query;
 
-      try {
-        const pagePathsWithDescendantCount = await Page.descendantCountByPaths(paths, req.user, userGroups, isIncludeEmpty, includeAnyoneWithTheLink);
-        return res.apiv3({ pagePathsWithDescendantCount });
-      }
-      catch (err) {
-        logger.error(err);
-        return res.apiv3Err(err);
-      }
-    },
-  ];
-};
+        try {
+          const pagePathsWithDescendantCount =
+            await Page.descendantCountByPaths(
+              paths,
+              req.user,
+              userGroups,
+              isIncludeEmpty,
+              includeAnyoneWithTheLink,
+            );
+          return res.apiv3({ pagePathsWithDescendantCount });
+        } catch (err) {
+          logger.error(err);
+          return res.apiv3Err(err);
+        }
+      },
+    ];
+  };

+ 23 - 13
apps/app/src/server/routes/apiv3/page/get-yjs-data.ts

@@ -1,11 +1,11 @@
 import type { IPage, 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 type { ValidationChain } from 'express-validator';
 import { param } from 'express-validator';
 import mongoose from 'mongoose';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { PageModel } from '~/server/models/page';
@@ -14,42 +14,52 @@ 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:page:get-yjs-data');
 
 type GetYjsDataHandlerFactory = (crowi: Crowi) => RequestHandler[];
 
 type ReqParams = {
-  pageId: string,
-}
+  pageId: string;
+};
 interface Req extends Request<ReqParams, ApiV3Response> {
-  user: IUserHasId,
+  user: IUserHasId;
 }
 export const getYjsDataHandlerFactory: GetYjsDataHandlerFactory = (crowi) => {
   const Page = mongoose.model<IPage, PageModel>('Page');
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(
+    crowi,
+  );
 
   // define validators for req.params
   const validator: ValidationChain[] = [
-    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
+    param('pageId')
+      .isMongoId()
+      .withMessage('The param "pageId" must be specified'),
   ];
 
   return [
-    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly,
-    validator, apiV3FormValidator,
-    async(req: Req, res: ApiV3Response) => {
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    validator,
+    apiV3FormValidator,
+    async (req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;
 
       // check whether accessible
       if (!(await Page.isAccessiblePageByViewer(pageId, req.user))) {
-        return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
+        return res.apiv3Err(
+          new ErrorV3(
+            'Current user is not accessible to this page.',
+            'forbidden-page',
+          ),
+          403,
+        );
       }
 
       try {
         const yjsData = await crowi.pageService.getYjsData(pageId);
         return res.apiv3({ yjsData });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(err);
       }

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 474 - 275
apps/app/src/server/routes/apiv3/page/index.ts


+ 19 - 14
apps/app/src/server/routes/apiv3/page/publish-page.ts

@@ -1,11 +1,11 @@
 import type { IPage, 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 type { ValidationChain } from 'express-validator';
 import { param } from 'express-validator';
 import mongoose from 'mongoose';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { PageModel } from '~/server/models/page';
@@ -14,34 +14,40 @@ 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:page:unpublish-page');
 
-
 type ReqParams = {
-  pageId: string,
-}
+  pageId: string;
+};
 
 interface Req extends Request<ReqParams, ApiV3Response> {
-  user: IUserHasId,
+  user: IUserHasId;
 }
 
 type PublishPageHandlersFactory = (crowi: Crowi) => RequestHandler[];
 
-export const publishPageHandlersFactory: PublishPageHandlersFactory = (crowi) => {
+export const publishPageHandlersFactory: PublishPageHandlersFactory = (
+  crowi,
+) => {
   const Page = mongoose.model<IPage, PageModel>('Page');
 
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(
+    crowi,
+  );
 
   // define validators for req.body
   const validator: ValidationChain[] = [
-    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
+    param('pageId')
+      .isMongoId()
+      .withMessage('The param "pageId" must be specified'),
   ];
 
   return [
-    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly,
-    validator, apiV3FormValidator,
-    async(req: Req, res: ApiV3Response) => {
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    validator,
+    apiV3FormValidator,
+    async (req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;
 
       try {
@@ -53,8 +59,7 @@ export const publishPageHandlersFactory: PublishPageHandlersFactory = (crowi) =>
         page.publish();
         const updatedPage = await page.save();
         return res.apiv3(updatedPage);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(err);
       }

+ 60 - 40
apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts

@@ -1,11 +1,11 @@
 import type { IPage, 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 type { ValidationChain } from 'express-validator';
-import { param, body } from 'express-validator';
+import { body, param } from 'express-validator';
 import mongoose from 'mongoose';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { PageModel } from '~/server/models/page';
@@ -15,51 +15,71 @@ 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:page:sync-latest-revision-body-to-yjs-draft',
+);
 
-const logger = loggerFactory('growi:routes:apiv3:page:sync-latest-revision-body-to-yjs-draft');
-
-type SyncLatestRevisionBodyToYjsDraftHandlerFactory = (crowi: Crowi) => RequestHandler[];
+type SyncLatestRevisionBodyToYjsDraftHandlerFactory = (
+  crowi: Crowi,
+) => RequestHandler[];
 
 type ReqParams = {
-  pageId: string,
-}
+  pageId: string;
+};
 type ReqBody = {
-  editingMarkdownLength?: number,
-}
+  editingMarkdownLength?: number;
+};
 interface Req extends Request<ReqParams, ApiV3Response, ReqBody> {
-  user: IUserHasId,
+  user: IUserHasId;
 }
-export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionBodyToYjsDraftHandlerFactory = (crowi) => {
-  const Page = mongoose.model<IPage, PageModel>('Page');
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionBodyToYjsDraftHandlerFactory =
+  (crowi) => {
+    const Page = mongoose.model<IPage, PageModel>('Page');
+    const loginRequiredStrictly =
+      require('../../../middlewares/login-required')(crowi);
 
-  // define validators for req.params
-  const validator: ValidationChain[] = [
-    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
-    body('editingMarkdownLength').optional().isInt().withMessage('The body "editingMarkdownLength" must be integer'),
-  ];
+    // define validators for req.params
+    const validator: ValidationChain[] = [
+      param('pageId')
+        .isMongoId()
+        .withMessage('The param "pageId" must be specified'),
+      body('editingMarkdownLength')
+        .optional()
+        .isInt()
+        .withMessage('The body "editingMarkdownLength" must be integer'),
+    ];
 
-  return [
-    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly,
-    validator, apiV3FormValidator,
-    async(req: Req, res: ApiV3Response) => {
-      const { pageId } = req.params;
-      const { editingMarkdownLength } = req.body;
+    return [
+      accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+      loginRequiredStrictly,
+      validator,
+      apiV3FormValidator,
+      async (req: Req, res: ApiV3Response) => {
+        const { pageId } = req.params;
+        const { editingMarkdownLength } = req.body;
 
-      // check whether accessible
-      if (!(await Page.isAccessiblePageByViewer(pageId, req.user))) {
-        return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
-      }
+        // check whether accessible
+        if (!(await Page.isAccessiblePageByViewer(pageId, req.user))) {
+          return res.apiv3Err(
+            new ErrorV3(
+              'Current user is not accessible to this page.',
+              'forbidden-page',
+            ),
+            403,
+          );
+        }
 
-      try {
-        const yjsService = getYjsService();
-        const result = await yjsService.syncWithTheLatestRevisionForce(pageId, editingMarkdownLength);
-        return res.apiv3(result);
-      }
-      catch (err) {
-        logger.error(err);
-        return res.apiv3Err(err);
-      }
-    },
-  ];
-};
+        try {
+          const yjsService = getYjsService();
+          const result = await yjsService.syncWithTheLatestRevisionForce(
+            pageId,
+            editingMarkdownLength,
+          );
+          return res.apiv3(result);
+        } catch (err) {
+          logger.error(err);
+          return res.apiv3Err(err);
+        }
+      },
+    ];
+  };

+ 19 - 14
apps/app/src/server/routes/apiv3/page/unpublish-page.ts

@@ -1,11 +1,11 @@
 import type { IPage, 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 type { ValidationChain } from 'express-validator';
 import { param } from 'express-validator';
 import mongoose from 'mongoose';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { PageModel } from '~/server/models/page';
@@ -14,34 +14,40 @@ 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:page:unpublish-page');
 
-
 type ReqParams = {
-  pageId: string,
-}
+  pageId: string;
+};
 
 interface Req extends Request<ReqParams, ApiV3Response> {
-  user: IUserHasId,
+  user: IUserHasId;
 }
 
 type UnpublishPageHandlersFactory = (crowi: Crowi) => RequestHandler[];
 
-export const unpublishPageHandlersFactory: UnpublishPageHandlersFactory = (crowi) => {
+export const unpublishPageHandlersFactory: UnpublishPageHandlersFactory = (
+  crowi,
+) => {
   const Page = mongoose.model<IPage, PageModel>('Page');
 
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(
+    crowi,
+  );
 
   // define validators for req.body
   const validator: ValidationChain[] = [
-    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
+    param('pageId')
+      .isMongoId()
+      .withMessage('The param "pageId" must be specified'),
   ];
 
   return [
-    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly,
-    validator, apiV3FormValidator,
-    async(req: Req, res: ApiV3Response) => {
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    validator,
+    apiV3FormValidator,
+    async (req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;
 
       try {
@@ -54,8 +60,7 @@ export const unpublishPageHandlersFactory: UnpublishPageHandlersFactory = (crowi
         const updatedPage = await page.save();
 
         return res.apiv3(updatedPage);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(err);
       }

+ 153 - 56
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -1,11 +1,12 @@
-import { Origin, allOrigin, getIdForRef } from '@growi/core';
-import type {
-  IPage, IRevisionHasId, IUserHasId,
-} from '@growi/core';
+import type { IPage, IRevisionHasId, IUserHasId } from '@growi/core';
+import { allOrigin, getIdForRef, Origin } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
-import { isTopPage, isUsersProtectedPages } from '@growi/core/dist/utils/page-path-utils';
+import {
+  isTopPage,
+  isUsersProtectedPages,
+} from '@growi/core/dist/utils/page-path-utils';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
@@ -14,14 +15,20 @@ import mongoose from 'mongoose';
 
 import { isAiEnabled } from '~/features/openai/server/services';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
-import { type IApiv3PageUpdateParams, PageUpdateErrorCode } from '~/interfaces/apiv3';
+import {
+  type IApiv3PageUpdateParams,
+  PageUpdateErrorCode,
+} from '~/interfaces/apiv3';
 import type { IOptionsForUpdate } from '~/interfaces/page';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageDocument, PageModel } from '~/server/models/page';
-import { serializePageSecurely, serializeRevisionSecurely } from '~/server/models/serializers';
+import {
+  serializePageSecurely,
+  serializeRevisionSecurely,
+} from '~/server/models/serializers';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
 import { getYjsService } from '~/server/service/yjs';
@@ -32,14 +39,12 @@ import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
-
 const logger = loggerFactory('growi:routes:apiv3:page:update-page');
 
-
 type ReqBody = IApiv3PageUpdateParams;
 
 interface UpdatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
-  user: IUserHasId,
+  user: IUserHasId;
 }
 
 type UpdatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
@@ -48,31 +53,63 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
   const Page = mongoose.model<IPage, PageModel>('Page');
   const Revision = mongoose.model<IRevisionHasId>('Revision');
 
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(
+    crowi,
+  );
 
   // define validators for req.body
   const validator: ValidationChain[] = [
-    body('pageId').isMongoId().exists().not()
+    body('pageId')
+      .isMongoId()
+      .exists()
+      .not()
       .isEmpty({ ignore_whitespace: true })
       .withMessage("'pageId' must be specified"),
-    body('revisionId').optional().exists().not()
+    body('revisionId')
+      .optional()
+      .exists()
+      .not()
       .isEmpty({ ignore_whitespace: true })
       .withMessage("'revisionId' must be specified"),
-    body('body').exists().isString()
+    body('body')
+      .exists()
+      .isString()
       .withMessage("Empty value is not allowed for 'body'"),
-    body('grant').optional().not().isString()
+    body('grant')
+      .optional()
+      .not()
+      .isString()
       .isInt({ min: 0, max: 5 })
       .withMessage('grant must be an integer from 1 to 5'),
-    body('userRelatedGrantUserGroupIds').optional().isArray().withMessage('userRelatedGrantUserGroupIds must be an array of group id'),
-    body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
-    body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
-    body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
-    body('origin').optional().isIn(allOrigin).withMessage('origin must be "view" or "editor"'),
+    body('userRelatedGrantUserGroupIds')
+      .optional()
+      .isArray()
+      .withMessage('userRelatedGrantUserGroupIds must be an array of group id'),
+    body('overwriteScopesOfDescendants')
+      .optional()
+      .isBoolean()
+      .withMessage('overwriteScopesOfDescendants must be boolean'),
+    body('isSlackEnabled')
+      .optional()
+      .isBoolean()
+      .withMessage('isSlackEnabled must be boolean'),
+    body('slackChannels')
+      .optional()
+      .isString()
+      .withMessage('slackChannels must be string'),
+    body('origin')
+      .optional()
+      .isIn(allOrigin)
+      .withMessage('origin must be "view" or "editor"'),
     body('wip').optional().isBoolean().withMessage('wip must be boolean'),
   ];
 
-
-  async function postAction(req: UpdatePageRequest, res: ApiV3Response, updatedPage: HydratedDocument<PageDocument>, previousRevision: IRevisionHasId | null) {
+  async function postAction(
+    req: UpdatePageRequest,
+    res: ApiV3Response,
+    updatedPage: HydratedDocument<PageDocument>,
+    previousRevision: IRevisionHasId | null,
+  ) {
     // Reflect the updates in ydoc
     const origin = req.body.origin;
     if (origin === Origin.View || origin === undefined) {
@@ -81,7 +118,10 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     }
 
     // persist activity
-    const creator = updatedPage.creator != null ? getIdForRef(updatedPage.creator) : undefined;
+    const creator =
+      updatedPage.creator != null
+        ? getIdForRef(updatedPage.creator)
+        : undefined;
     const parameters = {
       targetModel: SupportedTargetModel.MODEL_PAGE,
       target: updatedPage,
@@ -89,16 +129,21 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     };
     const activityEvent = crowi.event('activity');
     activityEvent.emit(
-      'update', res.locals.activity._id, parameters,
+      'update',
+      res.locals.activity._id,
+      parameters,
       { path: updatedPage.path, creator },
       preNotifyService.generatePreNotify,
     );
 
     // global notification
     try {
-      await crowi.globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_EDIT, updatedPage, req.user);
-    }
-    catch (err) {
+      await crowi.globalNotificationService.fire(
+        GlobalNotificationSettingEvent.PAGE_EDIT,
+        updatedPage,
+        req.user,
+      );
+    } catch (err) {
       logger.error('Edit notification failed', err);
     }
 
@@ -106,27 +151,34 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     const { isSlackEnabled, slackChannels } = req.body;
     if (isSlackEnabled) {
       try {
-        const option = previousRevision != null ? { previousRevision } : undefined;
-        const results = await crowi.userNotificationService.fire(updatedPage, req.user, slackChannels, 'update', option);
+        const option =
+          previousRevision != null ? { previousRevision } : undefined;
+        const results = await crowi.userNotificationService.fire(
+          updatedPage,
+          req.user,
+          slackChannels,
+          'update',
+          option,
+        );
         results.forEach((result) => {
           if (result.status === 'rejected') {
             logger.error('Create user notification failed', result.reason);
           }
         });
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Create user notification failed', err);
       }
     }
 
     // Rebuild vector store file
     if (isAiEnabled()) {
-      const { getOpenaiService } = await import('~/features/openai/server/services/openai');
+      const { getOpenaiService } = await import(
+        '~/features/openai/server/services/openai'
+      );
       try {
         const openaiService = getOpenaiService();
         await openaiService?.updateVectorStoreFileOnPageUpdate(updatedPage);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Rebuild vector store failed', err);
       }
     }
@@ -135,62 +187,102 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
   const addActivity = generateAddActivityMiddleware();
 
   return [
-    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly, excludeReadOnlyUser, addActivity,
-    validator, apiV3FormValidator,
-    async(req: UpdatePageRequest, res: ApiV3Response) => {
-      const {
-        pageId, revisionId, body, origin, grant,
-      } = req.body;
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    addActivity,
+    validator,
+    apiV3FormValidator,
+    async (req: UpdatePageRequest, res: ApiV3Response) => {
+      const { pageId, revisionId, body, origin, grant } = req.body;
 
-      const sanitizeRevisionId = revisionId == null ? undefined : generalXssFilter.process(revisionId);
+      const sanitizeRevisionId =
+        revisionId == null ? undefined : generalXssFilter.process(revisionId);
 
       // check page existence
-      const isExist = await Page.count({ _id: { $eq: pageId } }) > 0;
+      const isExist = (await Page.count({ _id: { $eq: pageId } })) > 0;
       if (!isExist) {
-        return res.apiv3Err(new ErrorV3(`Page('${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 400);
+        return res.apiv3Err(
+          new ErrorV3(
+            `Page('${pageId}' is not found or forbidden`,
+            'notfound_or_forbidden',
+          ),
+          400,
+        );
       }
 
       // check revision
       const currentPage = await Page.findByIdAndViewer(pageId, req.user);
       // check page existence (for type safety)
       if (currentPage == null) {
-        return res.apiv3Err(new ErrorV3(`Page('${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 400);
+        return res.apiv3Err(
+          new ErrorV3(
+            `Page('${pageId}' is not found or forbidden`,
+            'notfound_or_forbidden',
+          ),
+          400,
+        );
       }
 
-      const isGrantImmutable = isTopPage(currentPage.path) || isUsersProtectedPages(currentPage.path);
+      const isGrantImmutable =
+        isTopPage(currentPage.path) || isUsersProtectedPages(currentPage.path);
 
       if (grant != null && grant !== currentPage.grant && isGrantImmutable) {
-        return res.apiv3Err(new ErrorV3('The grant settings for the specified page cannot be modified.', PageUpdateErrorCode.FORBIDDEN), 403);
+        return res.apiv3Err(
+          new ErrorV3(
+            'The grant settings for the specified page cannot be modified.',
+            PageUpdateErrorCode.FORBIDDEN,
+          ),
+          403,
+        );
       }
 
       if (currentPage != null) {
         // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
         try {
           await normalizeLatestRevisionIfBroken(pageId);
-        }
-        catch (err) {
+        } catch (err) {
           logger.error('Error occurred in normalizing the latest revision');
         }
       }
 
-      if (currentPage != null && !await currentPage.isUpdatable(sanitizeRevisionId, origin)) {
-        const latestRevision = await Revision.findById(currentPage.revision).populate('author');
+      if (
+        currentPage != null &&
+        !(await currentPage.isUpdatable(sanitizeRevisionId, origin))
+      ) {
+        const latestRevision = await Revision.findById(
+          currentPage.revision,
+        ).populate('author');
         const returnLatestRevision = {
           revisionId: latestRevision?._id.toString(),
           revisionBody: latestRevision?.body,
           createdAt: latestRevision?.createdAt,
           user: serializeUserSecurely(latestRevision?.author),
         };
-        return res.apiv3Err(new ErrorV3('Posted param "revisionId" is outdated.', PageUpdateErrorCode.CONFLICT, undefined, { returnLatestRevision }), 409);
+        return res.apiv3Err(
+          new ErrorV3(
+            'Posted param "revisionId" is outdated.',
+            PageUpdateErrorCode.CONFLICT,
+            undefined,
+            { returnLatestRevision },
+          ),
+          409,
+        );
       }
 
       let updatedPage: HydratedDocument<PageDocument>;
       let previousRevision: IRevisionHasId | null;
       try {
         const {
-          userRelatedGrantUserGroupIds, overwriteScopesOfDescendants, wip,
+          userRelatedGrantUserGroupIds,
+          overwriteScopesOfDescendants,
+          wip,
         } = req.body;
-        const options: IOptionsForUpdate = { overwriteScopesOfDescendants, origin, wip };
+        const options: IOptionsForUpdate = {
+          overwriteScopesOfDescendants,
+          origin,
+          wip,
+        };
         if (grant != null) {
           options.grant = grant;
           options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;
@@ -199,9 +291,14 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
 
         // There are cases where "revisionId" is not required for revision updates
         // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
-        updatedPage = await crowi.pageService.updatePage(currentPage, body, previousRevision?.body ?? null, req.user, options);
-      }
-      catch (err) {
+        updatedPage = await crowi.pageService.updatePage(
+          currentPage,
+          body,
+          previousRevision?.body ?? null,
+          req.user,
+          options,
+        );
+      } catch (err) {
         logger.error('Error occurred while updating a page.', err);
         return res.apiv3Err(err);
       }

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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;
 };

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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'),
+        );
       }
     },
   ];

+ 1 - 1
biome.json

@@ -30,7 +30,7 @@
       "!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/*.js",
       "!apps/app/src/server/service"
     ]
   },

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio