diff --git a/apps/app/.eslintrc.js b/apps/app/.eslintrc.js index 14aca9f0bf2..9bc90909989 100644 --- a/apps/app/.eslintrc.js +++ b/apps/app/.eslintrc.js @@ -69,6 +69,8 @@ module.exports = { '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: { diff --git a/apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.integ.ts b/apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.integ.ts index dc5a8bc3158..0253a16eb34 100644 --- a/apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.integ.ts +++ b/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(); 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('/') diff --git a/apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.ts b/apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.ts index 1d01168725b..053d65fde4d 100644 --- a/apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.ts +++ b/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; }; diff --git a/apps/app/src/server/routes/apiv3/app-settings/index.ts b/apps/app/src/server/routes/apiv3/app-settings/index.ts index 809e2656f73..71a7eff40eb 100644 --- a/apps/app/src/server/routes/apiv3/app-settings/index.ts +++ b/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>; 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; }; diff --git a/apps/app/src/server/routes/apiv3/page/check-page-existence.ts b/apps/app/src/server/routes/apiv3/page/check-page-existence.ts index 9aec24b24bc..abe5b3437b3 100644 --- a/apps/app/src/server/routes/apiv3/page/check-page-existence.ts +++ b/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 { - user: IUserHasId, + user: IUserHasId; } type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[]; -export const checkPageExistenceHandlersFactory: CreatePageHandlersFactory = (crowi) => { +export const checkPageExistenceHandlersFactory: CreatePageHandlersFactory = ( + crowi, +) => { const Page = mongoose.model('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)) { diff --git a/apps/app/src/server/routes/apiv3/page/create-page.ts b/apps/app/src/server/routes/apiv3/page/create-page.ts index 7c6682dc9d3..88436767190 100644 --- a/apps/app/src/server/routes/apiv3/page/create-page.ts +++ b/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 { +async function generateUntitledPath( + parentPath: string, + basePathname: string, + index = 1, +): Promise { const Page = mongoose.model('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 { +async function determinePath( + _parentPath?: string, + _path?: string, + optionalParentPath?: string, +): Promise { 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 { - user: IUserHasId, + user: IUserHasId; } type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[]; export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => { const Page = mongoose.model('Page'); - const User = mongoose.model('User'); - - const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi); + const User = mongoose.model( + '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) { + async function postAction( + req: CreatePageRequest, + res: ApiV3Response, + createdPage: HydratedDocument, + ) { // 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; 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); } diff --git a/apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts b/apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts index edee93f8848..53830460ccb 100644 --- a/apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts +++ b/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 { - user: IUserHasId, + user: IUserHasId; } -export const getPagePathsWithDescendantCountFactory: GetPagePathsWithDescendantCountFactory = (crowi) => { - const Page = mongoose.model('Page'); - const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi); +export const getPagePathsWithDescendantCountFactory: GetPagePathsWithDescendantCountFactory = + (crowi) => { + const Page = mongoose.model('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); + } + }, + ]; + }; diff --git a/apps/app/src/server/routes/apiv3/page/get-yjs-data.ts b/apps/app/src/server/routes/apiv3/page/get-yjs-data.ts index 995cbf7aba3..ccefde6e375 100644 --- a/apps/app/src/server/routes/apiv3/page/get-yjs-data.ts +++ b/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 { - user: IUserHasId, + user: IUserHasId; } export const getYjsDataHandlerFactory: GetYjsDataHandlerFactory = (crowi) => { const Page = mongoose.model('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); } diff --git a/apps/app/src/server/routes/apiv3/page/index.ts b/apps/app/src/server/routes/apiv3/page/index.ts index 10dfec3b798..5af0cb5674a 100644 --- a/apps/app/src/server/routes/apiv3/page/index.ts +++ b/apps/app/src/server/routes/apiv3/page/index.ts @@ -1,25 +1,32 @@ -import path from 'path'; -import { type Readable } from 'stream'; -import { pipeline } from 'stream/promises'; - import type { - IDataWithMeta, IPage, IPageInfoExt, IPageNotFoundInfo, IRevision, + IDataWithMeta, + IPage, + IPageInfoExt, + IPageNotFoundInfo, + IRevision, } from '@growi/core'; import { + AllSubscriptionStatusType, + getIdForRef, getIdStringForRef, isIPageNotFoundInfo, - AllSubscriptionStatusType, PageGrant, SCOPE, SubscriptionStatusType, - getIdForRef, + PageGrant, + SCOPE, + SubscriptionStatusType, } from '@growi/core'; import { ErrorV3 } from '@growi/core/dist/models'; import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-utils'; import { normalizePath } from '@growi/core/dist/utils/path-utils'; import type { HydratedDocument } from 'mongoose'; import mongoose from 'mongoose'; +import path from 'path'; import sanitize from 'sanitize-filename'; +import type { Readable } from 'stream'; +import { pipeline } from 'stream/promises'; import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity'; import type { IPageGrantData } from '~/interfaces/page'; +import type { IRecordApplicableGrant } from '~/interfaces/page-grant'; import type Crowi from '~/server/crowi'; import { accessTokenParser } from '~/server/middlewares/access-token-parser'; import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity'; @@ -38,7 +45,6 @@ import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/norma import loggerFactory from '~/utils/logger'; import type { ApiV3Response } from '../interfaces/apiv3-response'; - import { checkPageExistenceHandlersFactory } from './check-page-existence'; import { createPageHandlersFactory } from './create-page'; import { getPagePathsWithDescendantCountFactory } from './get-page-paths-with-descendant-count'; @@ -48,7 +54,6 @@ import { syncLatestRevisionBodyToYjsDraftHandlerFactory } from './sync-latest-re import { unpublishPageHandlersFactory } from './unpublish-page'; import { updatePageHandlersFactory } from './update-page'; - const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars const express = require('express'); @@ -56,7 +61,6 @@ const { body, query, param } = require('express-validator'); const router = express.Router(); - /** * @swagger * @@ -76,9 +80,16 @@ const router = express.Router(); * */ module.exports = (crowi: Crowi) => { - const loginRequired = require('../../../middlewares/login-required')(crowi, true); - const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi); - const certifySharedPage = require('../../../middlewares/certify-shared-page')(crowi); + const loginRequired = require('../../../middlewares/login-required')( + crowi, + true, + ); + const loginRequiredStrictly = require('../../../middlewares/login-required')( + crowi, + ); + const certifySharedPage = require('../../../middlewares/certify-shared-page')( + crowi, + ); const addActivity = generateAddActivityMiddleware(); const globalNotificationService = crowi.getGlobalNotificationService(); @@ -95,28 +106,28 @@ module.exports = (crowi: Crowi) => { query('shareLinkId').optional().isMongoId(), query('includeEmpty').optional().isBoolean(), ], - likes: [ - body('pageId').isString(), - body('bool').isBoolean(), - ], - info: [ - query('pageId').isMongoId().withMessage('pageId is required'), - ], + likes: [body('pageId').isString(), body('bool').isBoolean()], + info: [query('pageId').isMongoId().withMessage('pageId is required')], getGrantData: [ query('pageId').isMongoId().withMessage('pageId is required'), ], - nonUserRelatedGroupsGranted: [ - query('path').isString(), - ], + nonUserRelatedGroupsGranted: [query('path').isString()], applicableGrant: [ query('pageId').isMongoId().withMessage('pageId is required'), ], updateGrant: [ param('pageId').isMongoId().withMessage('pageId is required'), body('grant').isInt().withMessage('grant is required'), - body('grantedGroups').optional().isArray().withMessage('grantedGroups must be an array'), - body('grantedGroups.*.type').isString().withMessage('grantedGroups type is required'), - body('grantedGroups.*.item').isMongoId().withMessage('grantedGroups item is required'), + body('grantedGroups') + .optional() + .isArray() + .withMessage('grantedGroups must be an array'), + body('grantedGroups.*.type') + .isString() + .withMessage('grantedGroups type is required'), + body('grantedGroups.*.item') + .isMongoId() + .withMessage('grantedGroups item is required'), ], export: [ query('format').isString().isIn(['md', 'pdf']), @@ -128,23 +139,18 @@ module.exports = (crowi: Crowi) => { body('isAttachmentFileDownload').isBoolean(), body('isSubordinatedPageDownload').isBoolean(), body('fileType').isString().isIn(['pdf', 'markdown']), - body('hierarchyType').isString().isIn(['allSubordinatedPage', 'decideHierarchy']), + body('hierarchyType') + .isString() + .isIn(['allSubordinatedPage', 'decideHierarchy']), body('hierarchyValue').isNumeric(), ], - exist: [ - query('fromPath').isString(), - query('toPath').isString(), - ], + exist: [query('fromPath').isString(), query('toPath').isString()], subscribe: [ body('pageId').isString(), body('status').isIn(AllSubscriptionStatusType), ], - subscribeStatus: [ - query('pageId').isString(), - ], - contentWidth: [ - body('expandContentWidth').isBoolean(), - ], + subscribeStatus: [query('pageId').isString()], + contentWidth: [body('expandContentWidth').isBoolean()], }; /** @@ -174,71 +180,129 @@ module.exports = (crowi: Crowi) => { * schema: * $ref: '#/components/schemas/Page' */ - router.get('/', + router.get( + '/', accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), - certifySharedPage, loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => { + certifySharedPage, + loginRequired, + validator.getPage, + apiV3FormValidator, + async (req, res) => { const { user, isSharedPage } = req; - const { - pageId, path, findAll, revisionId, shareLinkId, includeEmpty, - } = req.query; + const { pageId, path, findAll, revisionId, shareLinkId, includeEmpty } = + req.query; + + const respondWithSinglePage = async ( + pageWithMeta: + | IDataWithMeta, IPageInfoExt> + | IDataWithMeta, + ) => { + let { data: page } = pageWithMeta; + const { meta } = pageWithMeta; + + if (isIPageNotFoundInfo(meta)) { + if (meta.isForbidden) { + return res.apiv3Err( + new ErrorV3( + 'Page is forbidden', + 'page-is-forbidden', + undefined, + meta, + ), + 403, + ); + } + return res.apiv3Err( + new ErrorV3('Page is not found', 'page-not-found', undefined, meta), + 404, + ); + } + + if (page != null) { + try { + page.initLatestRevisionField(revisionId); + + // populate + page = await page.populateDataToShowRevision(); + } catch (err) { + logger.error('populate-page-failed', err); + return res.apiv3Err( + new ErrorV3( + 'Failed to populate page', + 'populate-page-failed', + undefined, + { err, meta }, + ), + 500, + ); + } + } + + return res.apiv3({ page, pages: undefined, meta }); + }; - const isValid = (shareLinkId != null && pageId != null && path == null) || (shareLinkId == null && (pageId != null || path != null)); + const isValid = + (shareLinkId != null && pageId != null && path == null) || + (shareLinkId == null && (pageId != null || path != null)); if (!isValid) { - return res.apiv3Err(new Error('Either parameter of (pageId or path) or (pageId and shareLinkId) is required.'), 400); + return res.apiv3Err( + new Error( + 'Either parameter of (pageId or path) or (pageId and shareLinkId) is required.', + ), + 400, + ); } - let pageWithMeta: IDataWithMeta, IPageInfoExt> | IDataWithMeta = { - data: null, - }; - let pages; try { if (isSharedPage) { - const shareLink = await ShareLink.findOne({ _id: { $eq: shareLinkId } }); + const shareLink = await ShareLink.findOne({ + _id: { $eq: shareLinkId }, + }); if (shareLink == null) { return res.apiv3Err('ShareLink is not found', 404); } - pageWithMeta = await pageService.findPageAndMetaDataByViewer(getIdStringForRef(shareLink.relatedPage), path, user, true); + return respondWithSinglePage( + await pageService.findPageAndMetaDataByViewer( + getIdStringForRef(shareLink.relatedPage), + path, + user, + true, + ), + ); } - else if (!findAll) { - pageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, path, user); - } - else { - pages = await Page.findByPathAndViewer(path, user, null, false, includeEmpty); - } - } - catch (err) { - logger.error('get-page-failed', err); - return res.apiv3Err(err, 500); - } - - let { data: page } = pageWithMeta; - const { meta } = pageWithMeta; - // not found or forbidden - if (isIPageNotFoundInfo(meta) || (Array.isArray(pages) && pages.length === 0)) { - if (isIPageNotFoundInfo(meta) && meta.isForbidden) { - return res.apiv3Err(new ErrorV3('Page is forbidden', 'page-is-forbidden', undefined, meta), 403); + if (findAll != null) { + const pages = await Page.findByPathAndViewer( + path, + user, + null, + false, + includeEmpty, + ); + + if (pages.length === 0) { + return res.apiv3Err( + new ErrorV3('Page is not found', 'page-not-found'), + 404, + ); + } + return res.apiv3({ page: undefined, pages, meta: undefined }); } - return res.apiv3Err(new ErrorV3('Page is not found', 'page-not-found', undefined, meta), 404); - } - - if (page != null) { - try { - page.initLatestRevisionField(revisionId); - // populate - page = await page.populateDataToShowRevision(); - } - catch (err) { - logger.error('populate-page-failed', err); - return res.apiv3Err(new ErrorV3('Failed to populate page', 'populate-page-failed', undefined, { err, meta }), 500); - } + return respondWithSinglePage( + await pageService.findPageAndMetaDataByViewer(pageId, path, user), + ); + } catch (err) { + logger.error('get-page-failed', err); + return res.apiv3Err(err, 500); } + }, + ); - return res.apiv3({ page, pages, meta }); - }); - - router.get('/page-paths-with-descendant-count', getPagePathsWithDescendantCountFactory(crowi)); + router.get( + '/page-paths-with-descendant-count', + getPagePathsWithDescendantCountFactory(crowi), + ); /** * @swagger @@ -420,11 +484,17 @@ module.exports = (crowi: Crowi) => { * schema: * $ref: '#/components/schemas/Page' */ - router.put('/likes', accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly, addActivity, - validator.likes, apiV3FormValidator, async(req, res) => { + router.put( + '/likes', + accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), + loginRequiredStrictly, + addActivity, + validator.likes, + apiV3FormValidator, + async (req, res) => { const { pageId, bool: isLiked } = req.body; - let page; + let page: HydratedDocument | null; try { page = await Page.findByIdAndViewer(pageId, req.user); if (page == null) { @@ -433,39 +503,48 @@ module.exports = (crowi: Crowi) => { if (isLiked) { page = await page.like(req.user); - } - else { + } else { page = await page.unlike(req.user); } - } - catch (err) { + } catch (err) { logger.error('update-like-failed', err); return res.apiv3Err(err, 500); } - const result = { page, seenUser: page.seenUsers }; + const result = { page, seenUser: page?.seenUsers }; const parameters = { targetModel: SupportedTargetModel.MODEL_PAGE, target: page, - action: isLiked ? SupportedAction.ACTION_PAGE_LIKE : SupportedAction.ACTION_PAGE_UNLIKE, + action: isLiked + ? SupportedAction.ACTION_PAGE_LIKE + : SupportedAction.ACTION_PAGE_UNLIKE, }; - activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify); - + activityEvent.emit( + 'update', + res.locals.activity._id, + parameters, + page, + preNotifyService.generatePreNotify, + ); res.apiv3({ result }); if (isLiked) { try { - // global notification - await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_LIKE, page, req.user); - } - catch (err) { + // global notification + await globalNotificationService.fire( + GlobalNotificationSettingEvent.PAGE_LIKE, + page, + req.user, + ); + } catch (err) { logger.error('Like notification failed', err); } } - }); + }, + ); /** * @swagger @@ -492,25 +571,36 @@ module.exports = (crowi: Crowi) => { * 500: * description: Internal server error. */ - router.get('/info', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), certifySharedPage, loginRequired, validator.info, apiV3FormValidator, async(req, res) => { - const { user, isSharedPage } = req; - const { pageId } = req.query; + router.get( + '/info', + accessTokenParser([SCOPE.READ.FEATURES.PAGE]), + certifySharedPage, + loginRequired, + validator.info, + apiV3FormValidator, + async (req, res) => { + const { user, isSharedPage } = req; + const { pageId } = req.query; - try { - const { meta } = await pageService.findPageAndMetaDataByViewer(pageId, null, user, isSharedPage); + try { + const { meta } = await pageService.findPageAndMetaDataByViewer( + pageId, + null, + user, + isSharedPage, + ); + + if (isIPageNotFoundInfo(meta)) { + return res.apiv3Err(`Page '${pageId}' is not found or forbidden`); + } - if (isIPageNotFoundInfo(meta)) { - return res.apiv3Err(`Page '${pageId}' is not found or forbidden`); + return res.apiv3(meta); + } catch (err) { + logger.error('get-page-info', err); + return res.apiv3Err(err, 500); } - - return res.apiv3(meta); - } - catch (err) { - logger.error('get-page-info', err); - return res.apiv3Err(err, 500); - } - - }); + }, + ); /** * @swagger @@ -541,8 +631,13 @@ module.exports = (crowi: Crowi) => { * 500: * description: Internal server error. */ - router.get('/grant-data', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly, - validator.getGrantData, apiV3FormValidator, async(req, res) => { + router.get( + '/grant-data', + accessTokenParser([SCOPE.READ.FEATURES.PAGE]), + loginRequiredStrictly, + validator.getGrantData, + apiV3FormValidator, + async (req, res) => { const { pageId } = req.query; const Page = mongoose.model('Page'); @@ -551,24 +646,36 @@ module.exports = (crowi: Crowi) => { const page = await Page.findByIdAndViewer(pageId, req.user, null, false); if (page == null) { - // Empty page should not be related to grant API - return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400); + // Empty page should not be related to grant API + return res.apiv3Err( + new ErrorV3( + 'Page is unreachable or empty.', + 'page_unreachable_or_empty', + ), + 400, + ); } - const { - path, grant, grantedUsers, grantedGroups, - } = page; + const { path, grant, grantedUsers, grantedGroups } = page; let isGrantNormalized = false; try { - const grantedUsersId = grantedUsers.map(ref => getIdForRef(ref)); - isGrantNormalized = await pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsersId, grantedGroups, false, false); - } - catch (err) { + const grantedUsersId = grantedUsers.map((ref) => getIdForRef(ref)); + isGrantNormalized = await pageGrantService.isGrantNormalized( + req.user, + path, + grant, + grantedUsersId, + grantedGroups, + false, + false, + ); + } catch (err) { logger.error('Error occurred while processing isGrantNormalized.', err); return res.apiv3Err(err, 500); } - const currentPageGroupGrantData = await pageGrantService.getPageGroupGrantData(page, req.user); + const currentPageGroupGrantData = + await pageGrantService.getPageGroupGrantData(page, req.user); const currentPageGrant: IPageGrantData = { grant: page.grant, groupGrantData: currentPageGroupGrantData, @@ -584,7 +691,12 @@ module.exports = (crowi: Crowi) => { return res.apiv3({ isGrantNormalized, grantData }); } - const parentPage = await Page.findByIdAndViewer(getIdForRef(page.parent), req.user, null, false); + const parentPage = await Page.findByIdAndViewer( + getIdForRef(page.parent), + req.user, + null, + false, + ); // user isn't allowed to see parent's grant if (parentPage == null) { @@ -596,7 +708,8 @@ module.exports = (crowi: Crowi) => { return res.apiv3({ isGrantNormalized, grantData }); } - const parentPageGroupGrantData = await pageGrantService.getPageGroupGrantData(parentPage, req.user); + const parentPageGroupGrantData = + await pageGrantService.getPageGroupGrantData(parentPage, req.user); const parentPageGrant: IPageGrantData = { grant, groupGrantData: parentPageGroupGrantData, @@ -609,7 +722,8 @@ module.exports = (crowi: Crowi) => { }; return res.apiv3({ isGrantNormalized, grantData }); - }); + }, + ); // Check if non user related groups are granted page access. // If specified page does not exist, check the closest ancestor. @@ -644,38 +758,66 @@ module.exports = (crowi: Crowi) => { * 500: * description: Internal server error. */ - router.get('/non-user-related-groups-granted', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly, - validator.nonUserRelatedGroupsGranted, apiV3FormValidator, - async(req, res: ApiV3Response) => { + router.get( + '/non-user-related-groups-granted', + accessTokenParser([SCOPE.READ.FEATURES.PAGE]), + loginRequiredStrictly, + validator.nonUserRelatedGroupsGranted, + apiV3FormValidator, + async (req, res: ApiV3Response) => { const { user } = req; const path = normalizePath(req.query.path); const pageGrantService = crowi.pageGrantService as IPageGrantService; try { - const page = await Page.findByPath(path, true) ?? await Page.findNonEmptyClosestAncestor(path); + const page = + (await Page.findByPath(path, true)) ?? + (await Page.findNonEmptyClosestAncestor(path)); if (page == null) { // 'page' should always be non empty, since every page stems back to root page. // If it is empty, there is a problem with the server logic. - return res.apiv3Err(new ErrorV3('No page on the page tree could be retrived.', 'page_could_not_be_retrieved'), 500); + return res.apiv3Err( + new ErrorV3( + 'No page on the page tree could be retrived.', + 'page_could_not_be_retrieved', + ), + 500, + ); } - const userRelatedGroups = await pageGrantService.getUserRelatedGroups(user); - const isUserGrantedPageAccess = await pageGrantService.isUserGrantedPageAccess(page, user, userRelatedGroups, true); + const userRelatedGroups = + await pageGrantService.getUserRelatedGroups(user); + const isUserGrantedPageAccess = + await pageGrantService.isUserGrantedPageAccess( + page, + user, + userRelatedGroups, + true, + ); if (!isUserGrantedPageAccess) { - return res.apiv3Err(new ErrorV3('Cannot access page or ancestor.', 'cannot_access_page'), 403); + return res.apiv3Err( + new ErrorV3( + 'Cannot access page or ancestor.', + 'cannot_access_page', + ), + 403, + ); } if (page.grant !== PageGrant.GRANT_USER_GROUP) { return res.apiv3({ isNonUserRelatedGroupsGranted: false }); } - const nonUserRelatedGrantedGroups = await pageGrantService.getNonUserRelatedGrantedGroups(page, user); - return res.apiv3({ isNonUserRelatedGroupsGranted: nonUserRelatedGrantedGroups.length > 0 }); - } - catch (err) { + const nonUserRelatedGrantedGroups = + await pageGrantService.getNonUserRelatedGrantedGroups(page, user); + return res.apiv3({ + isNonUserRelatedGroupsGranted: nonUserRelatedGrantedGroups.length > 0, + }); + } catch (err) { logger.error(err); return res.apiv3Err(err, 500); } - }); + }, + ); /** * @swagger * /page/applicable-grant: @@ -715,29 +857,46 @@ module.exports = (crowi: Crowi) => { * 500: * description: Internal server error. */ - router.get('/applicable-grant', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly, validator.applicableGrant, apiV3FormValidator, - async(req, res) => { + router.get( + '/applicable-grant', + accessTokenParser([SCOPE.READ.FEATURES.PAGE]), + loginRequiredStrictly, + validator.applicableGrant, + apiV3FormValidator, + async (req, res) => { const { pageId } = req.query; const Page = mongoose.model('Page'); const page = await Page.findByIdAndViewer(pageId, req.user, null); if (page == null) { - // Empty page should not be related to grant API - return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400); + // Empty page should not be related to grant API + return res.apiv3Err( + new ErrorV3( + 'Page is unreachable or empty.', + 'page_unreachable_or_empty', + ), + 400, + ); } - let data; + let data: IRecordApplicableGrant; try { - data = await crowi.pageGrantService.calcApplicableGrantData(page, req.user); - } - catch (err) { - logger.error('Error occurred while processing calcApplicableGrantData.', err); + data = await crowi.pageGrantService.calcApplicableGrantData( + page, + req.user, + ); + } catch (err) { + logger.error( + 'Error occurred while processing calcApplicableGrantData.', + err, + ); return res.apiv3Err(err, 500); } return res.apiv3(data); - }); + }, + ); /** * @swagger @@ -776,9 +935,14 @@ module.exports = (crowi: Crowi) => { * schema: * $ref: '#/components/schemas/Page' */ - router.put('/:pageId/grant', accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly, excludeReadOnlyUser, - validator.updateGrant, apiV3FormValidator, - async(req, res) => { + router.put( + '/:pageId/grant', + accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), + loginRequiredStrictly, + excludeReadOnlyUser, + validator.updateGrant, + apiV3FormValidator, + async (req, res) => { const { pageId } = req.params; const { grant, userRelatedGrantedGroups } = req.body; @@ -787,144 +951,164 @@ module.exports = (crowi: Crowi) => { const page = await Page.findByIdAndViewer(pageId, req.user, null, false); if (page == null) { - // Empty page should not be related to grant API - return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400); + // Empty page should not be related to grant API + return res.apiv3Err( + new ErrorV3( + 'Page is unreachable or empty.', + 'page_unreachable_or_empty', + ), + 400, + ); } - let data; + let data: PageDocument; try { const grantData = { grant, userRelatedGrantedGroups }; data = await crowi.pageService.updateGrant(page, req.user, grantData); - } - catch (err) { - logger.error('Error occurred while processing calcApplicableGrantData.', err); + } catch (err) { + logger.error( + 'Error occurred while processing calcApplicableGrantData.', + err, + ); return res.apiv3Err(err, 500); } return res.apiv3(data); - }); + }, + ); /** - * @swagger - * - * /page/export/{pageId}: - * get: - * tags: [Page] - * security: - * - cookieAuth: [] - * description: return page's markdown - * parameters: - * - name: pageId - * in: path - * description: ID of the page - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Return page's markdown - */ - router.get('/export/:pageId', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly, validator.export, async(req, res) => { - const pageId: string = req.params.pageId; - const format: 'md' | 'pdf' = req.query.format ?? 'md'; - const revisionId: string | undefined = req.query.revisionId; - - let revision: HydratedDocument | null; - let pagePath; - - const Page = mongoose.model, PageModel>('Page'); - - let page: HydratedDocument | null; - - try { - page = await Page.findByIdAndViewer(pageId, req.user); + * @swagger + * + * /page/export/{pageId}: + * get: + * tags: [Page] + * security: + * - cookieAuth: [] + * description: return page's markdown + * parameters: + * - name: pageId + * in: path + * description: ID of the page + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Return page's markdown + */ + router.get( + '/export/:pageId', + accessTokenParser([SCOPE.READ.FEATURES.PAGE]), + loginRequiredStrictly, + validator.export, + async (req, res) => { + const pageId: string = req.params.pageId; + const format: 'md' | 'pdf' = req.query.format ?? 'md'; + const revisionId: string | undefined = req.query.revisionId; - if (page == null) { - const isPageExist = await Page.count({ _id: pageId }) > 0; - if (isPageExist) { - // This page exists but req.user has not read permission - return res.apiv3Err(new ErrorV3(`Haven't the right to see the page ${pageId}.`), 403); + let revision: HydratedDocument | null; + let pagePath: string; + + const Page = mongoose.model, PageModel>( + 'Page', + ); + + let page: HydratedDocument | null; + + try { + page = await Page.findByIdAndViewer(pageId, req.user); + + if (page == null) { + const isPageExist = (await Page.count({ _id: pageId })) > 0; + if (isPageExist) { + // This page exists but req.user has not read permission + return res.apiv3Err( + new ErrorV3(`Haven't the right to see the page ${pageId}.`), + 403, + ); + } + return res.apiv3Err(new ErrorV3(`Page ${pageId} is not exist.`), 404); } - return res.apiv3Err(new ErrorV3(`Page ${pageId} is not exist.`), 404); + } catch (err) { + logger.error('Failed to get page data', err); + return res.apiv3Err(err, 500); } - } - catch (err) { - logger.error('Failed to get page data', err); - return res.apiv3Err(err, 500); - } - - // 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) { - logger.error('Error occurred in normalizing the latest revision'); - } - - try { - const targetId = revisionId ?? (page.revision != null ? getIdForRef(page.revision) : null); - if (targetId == null) { - throw new Error('revisionId is not specified'); + + // 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) { + logger.error('Error occurred in normalizing the latest revision'); } - const revisionIdForFind = new mongoose.Types.ObjectId(targetId); - revision = await Revision.findById(revisionIdForFind); - if (revision == null) { - throw new Error('Revision is not found'); + try { + const targetId = + revisionId ?? + (page.revision != null ? getIdForRef(page.revision) : null); + if (targetId == null) { + throw new Error('revisionId is not specified'); + } + + const revisionIdForFind = new mongoose.Types.ObjectId(targetId); + revision = await Revision.findById(revisionIdForFind); + if (revision == null) { + throw new Error('Revision is not found'); + } + + pagePath = page.path; + + // Error if pageId and revison's pageIds do not match + if (page._id.toString() !== revision.pageId.toString()) { + return res.apiv3Err( + new ErrorV3("Haven't the right to see the page."), + 403, + ); + } + } catch (err) { + logger.error('Failed to get revision data', err); + return res.apiv3Err(err, 500); } - pagePath = page.path; + // replace forbidden characters to '_' + // refer to https://kb.acronis.com/node/56475?ckattempt=1 + let fileName = sanitize(path.basename(pagePath), { replacement: '_' }); - // Error if pageId and revison's pageIds do not match - if (page._id.toString() !== revision.pageId.toString()) { - return res.apiv3Err(new ErrorV3("Haven't the right to see the page."), 403); + // replace root page name to '_top' + if (fileName === '') { + fileName = '_top'; } - } - catch (err) { - logger.error('Failed to get revision data', err); - return res.apiv3Err(err, 500); - } - // replace forbidden characters to '_' - // refer to https://kb.acronis.com/node/56475?ckattempt=1 - let fileName = sanitize(path.basename(pagePath), { replacement: '_' }); + let stream: Readable; + try { + if (exportService == null) { + throw new Error('exportService is not initialized'); + } + stream = exportService.getReadStreamFromRevision(revision, format); + } catch (err) { + logger.error('Failed to create readStream', err); + return res.apiv3Err(err, 500); + } - // replace root page name to '_top' - if (fileName === '') { - fileName = '_top'; - } + res.set({ + 'Content-Disposition': `attachment;filename*=UTF-8''${encodeURIComponent(fileName)}.${format}`, + }); - let stream: Readable; + const parameters = { + ip: req.ip, + endpoint: req.originalUrl, + action: SupportedAction.ACTION_PAGE_EXPORT, + user: req.user?._id, + snapshot: { + username: req.user?.username, + }, + }; + await crowi.activityService.createActivity(parameters); - try { - if (exportService == null) { - throw new Error('exportService is not initialized'); - } - stream = exportService.getReadStreamFromRevision(revision, format); - } - catch (err) { - logger.error('Failed to create readStream', err); - return res.apiv3Err(err, 500); - } - - res.set({ - 'Content-Disposition': `attachment;filename*=UTF-8''${encodeURIComponent(fileName)}.${format}`, - }); - - const parameters = { - ip: req.ip, - endpoint: req.originalUrl, - action: SupportedAction.ACTION_PAGE_EXPORT, - user: req.user?._id, - snapshot: { - username: req.user?.username, - }, - }; - await crowi.activityService.createActivity(parameters); - - await pipeline(stream, res); - }); + await pipeline(stream, res); + }, + ); /** * @swagger @@ -960,33 +1144,54 @@ module.exports = (crowi: Crowi) => { * 500: * description: Internal server error. */ - router.get('/exist-paths', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequired, validator.exist, apiV3FormValidator, async(req, res) => { - const { fromPath, toPath } = req.query; - - try { - const fromPage = await Page.findByPath(fromPath, true); - if (fromPage == null) { - return res.apiv3Err(new ErrorV3('fromPage is not exist', 'from-page-is-not-exist'), 400); - } - - const fromPageDescendants = await Page.findManageableListWithDescendants(fromPage, req.user, {}, true); - - const toPathDescendantsArray = fromPageDescendants.map((subordinatedPage) => { - return convertToNewAffiliationPath(fromPath, toPath, subordinatedPage.path); - }); + router.get( + '/exist-paths', + accessTokenParser([SCOPE.READ.FEATURES.PAGE]), + loginRequired, + validator.exist, + apiV3FormValidator, + async (req, res) => { + const { fromPath, toPath } = req.query; - const existPages = await Page.findListByPathsArray(toPathDescendantsArray); - const existPaths = existPages.map(page => page.path); - - return res.apiv3({ existPaths }); - - } - catch (err) { - logger.error('Failed to get exist path', err); - return res.apiv3Err(err, 500); - } + try { + const fromPage = await Page.findByPath(fromPath, true); + if (fromPage == null) { + return res.apiv3Err( + new ErrorV3('fromPage is not exist', 'from-page-is-not-exist'), + 400, + ); + } - }); + const fromPageDescendants = + await Page.findManageableListWithDescendants( + fromPage, + req.user, + {}, + true, + ); + + const toPathDescendantsArray = fromPageDescendants.map( + (subordinatedPage) => { + return convertToNewAffiliationPath( + fromPath, + toPath, + subordinatedPage.path, + ); + }, + ); + + const existPages = await Page.findListByPathsArray( + toPathDescendantsArray, + ); + const existPaths = existPages.map((page) => page.path); + + return res.apiv3({ existPaths }); + } catch (err) { + logger.error('Failed to get exist path', err); + return res.apiv3Err(err, 500); + } + }, + ); /** * @swagger @@ -1013,34 +1218,45 @@ module.exports = (crowi: Crowi) => { * 500: * description: Internal server error. */ - router.put('/subscribe', accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly, addActivity, - validator.subscribe, apiV3FormValidator, - async(req, res) => { + router.put( + '/subscribe', + accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), + loginRequiredStrictly, + addActivity, + validator.subscribe, + apiV3FormValidator, + async (req, res) => { const { pageId, status } = req.body; const userId = req.user._id; try { - const subscription = await Subscription.subscribeByPageId(userId, pageId, status); + const subscription = await Subscription.subscribeByPageId( + userId, + pageId, + status, + ); const parameters = {}; if (SubscriptionStatusType.SUBSCRIBE === status) { - Object.assign(parameters, { action: SupportedAction.ACTION_PAGE_SUBSCRIBE }); - } - else if (SubscriptionStatusType.UNSUBSCRIBE === status) { - Object.assign(parameters, { action: SupportedAction.ACTION_PAGE_UNSUBSCRIBE }); + Object.assign(parameters, { + action: SupportedAction.ACTION_PAGE_SUBSCRIBE, + }); + } else if (SubscriptionStatusType.UNSUBSCRIBE === status) { + Object.assign(parameters, { + action: SupportedAction.ACTION_PAGE_UNSUBSCRIBE, + }); } if ('action' in parameters) { activityEvent.emit('update', res.locals.activity._id, parameters); } return res.apiv3({ subscription }); - } - catch (err) { + } catch (err) { logger.error('Failed to update subscribe status', err); return res.apiv3Err(err, 500); } - }); - + }, + ); /** * @swagger @@ -1075,26 +1291,35 @@ module.exports = (crowi: Crowi) => { * page: * $ref: '#/components/schemas/Page' */ - router.put('/:pageId/content-width', accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly, excludeReadOnlyUser, - validator.contentWidth, apiV3FormValidator, async(req, res) => { + router.put( + '/:pageId/content-width', + accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), + loginRequiredStrictly, + excludeReadOnlyUser, + validator.contentWidth, + apiV3FormValidator, + async (req, res) => { const { pageId } = req.params; const { expandContentWidth } = req.body; - const isContainerFluidBySystem = configManager.getConfig('customize:isContainerFluid'); + const isContainerFluidBySystem = configManager.getConfig( + 'customize:isContainerFluid', + ); try { - const updateQuery = expandContentWidth === isContainerFluidBySystem - ? { $unset: { expandContentWidth } } // remove if the specified value is the same to the system's one - : { $set: { expandContentWidth } }; + const updateQuery = + expandContentWidth === isContainerFluidBySystem + ? { $unset: { expandContentWidth } } // remove if the specified value is the same to the system's one + : { $set: { expandContentWidth } }; const page = await Page.updateOne({ _id: pageId }, updateQuery); return res.apiv3({ page }); - } - catch (err) { + } catch (err) { logger.error('update-content-width-failed', err); return res.apiv3Err(err, 500); } - }); + }, + ); /** * @swagger @@ -1216,7 +1441,10 @@ module.exports = (crowi: Crowi) => { * type: boolean * description: Whether Yjs data is broken */ - router.put('/:pageId/sync-latest-revision-body-to-yjs-draft', syncLatestRevisionBodyToYjsDraftHandlerFactory(crowi)); + router.put( + '/:pageId/sync-latest-revision-body-to-yjs-draft', + syncLatestRevisionBodyToYjsDraftHandlerFactory(crowi), + ); return router; }; diff --git a/apps/app/src/server/routes/apiv3/page/publish-page.ts b/apps/app/src/server/routes/apiv3/page/publish-page.ts index 20809905b3b..8759b22d225 100644 --- a/apps/app/src/server/routes/apiv3/page/publish-page.ts +++ b/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 { - user: IUserHasId, + user: IUserHasId; } type PublishPageHandlersFactory = (crowi: Crowi) => RequestHandler[]; -export const publishPageHandlersFactory: PublishPageHandlersFactory = (crowi) => { +export const publishPageHandlersFactory: PublishPageHandlersFactory = ( + crowi, +) => { const Page = mongoose.model('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); } diff --git a/apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts b/apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts index 4bff6771329..29d86aa18f0 100644 --- a/apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts +++ b/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 { - user: IUserHasId, + user: IUserHasId; } -export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionBodyToYjsDraftHandlerFactory = (crowi) => { - const Page = mongoose.model('Page'); - const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi); +export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionBodyToYjsDraftHandlerFactory = + (crowi) => { + const Page = mongoose.model('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); + } + }, + ]; + }; diff --git a/apps/app/src/server/routes/apiv3/page/unpublish-page.ts b/apps/app/src/server/routes/apiv3/page/unpublish-page.ts index 2dc331824db..830f14cc8b1 100644 --- a/apps/app/src/server/routes/apiv3/page/unpublish-page.ts +++ b/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 { - user: IUserHasId, + user: IUserHasId; } type UnpublishPageHandlersFactory = (crowi: Crowi) => RequestHandler[]; -export const unpublishPageHandlersFactory: UnpublishPageHandlersFactory = (crowi) => { +export const unpublishPageHandlersFactory: UnpublishPageHandlersFactory = ( + crowi, +) => { const Page = mongoose.model('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); } diff --git a/apps/app/src/server/routes/apiv3/page/update-page.ts b/apps/app/src/server/routes/apiv3/page/update-page.ts index d58cf1e3ccf..47969d10aab 100644 --- a/apps/app/src/server/routes/apiv3/page/update-page.ts +++ b/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 { - user: IUserHasId, + user: IUserHasId; } type UpdatePageHandlersFactory = (crowi: Crowi) => RequestHandler[]; @@ -48,31 +53,63 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => { const Page = mongoose.model('Page'); const Revision = mongoose.model('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, previousRevision: IRevisionHasId | null) { + async function postAction( + req: UpdatePageRequest, + res: ApiV3Response, + updatedPage: HydratedDocument, + 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; 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); } diff --git a/biome.json b/biome.json index 24c3df54b8c..62eb8828cdf 100644 --- a/biome.json +++ b/biome.json @@ -30,8 +30,6 @@ "!packages/pdf-converter-client/specs", "!apps/app/src/client", "!apps/app/src/server/middlewares", - "!apps/app/src/server/routes/apiv3/app-settings", - "!apps/app/src/server/routes/apiv3/page", "!apps/app/src/server/routes/apiv3/*.js", "!apps/app/src/server/service" ]