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

Merge pull request #7639 from weseek/feat/120699-121339-create-validator

feat: Apply read-only-validator.ts
Ryoji Shimizu 2 лет назад
Родитель
Сommit
30db34d29f

+ 25 - 0
apps/app/src/server/middlewares/exclude-read-only-user.ts

@@ -0,0 +1,25 @@
+import { ErrorV3 } from '@growi/core';
+import { NextFunction, Response } from 'express';
+import { Request } from 'express-validator/src/base';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:middleware:exclude-read-only-user');
+
+export const excludeReadOnlyUser = (req: Request, res: Response & { apiv3Err }, next: () => NextFunction): NextFunction => {
+  const user = req.user;
+
+  if (user == null) {
+    logger.warn('req.user is null');
+    return next();
+  }
+
+  if (user.readOnly) {
+    const message = 'This user is read only user';
+    logger.warn(message);
+
+    return res.apiv3Err(new ErrorV3(message, 'validation_failed'));
+  }
+
+  return next();
+};

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

@@ -4,12 +4,12 @@ import {
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
 import Subscription from '~/server/models/subscription';
 import UserGroup from '~/server/models/user-group';
 import loggerFactory from '~/utils/logger';
 
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -542,7 +542,7 @@ module.exports = (crowi) => {
     return res.apiv3(data);
   });
 
-  router.put('/:pageId/grant', loginRequiredStrictly, validator.updateGrant, apiV3FormValidator, async(req, res) => {
+  router.put('/:pageId/grant', loginRequiredStrictly, excludeReadOnlyUser, validator.updateGrant, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.params;
     const { grant, grantedGroup } = req.body;
 
@@ -837,7 +837,7 @@ module.exports = (crowi) => {
   });
 
 
-  router.put('/:pageId/content-width', accessTokenParser, loginRequiredStrictly,
+  router.put('/:pageId/content-width', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser,
     validator.contentWidth, apiV3FormValidator, async(req, res) => {
       const { pageId } = req.params;
       const { expandContentWidth } = req.body;

+ 79 - 76
apps/app/src/server/routes/apiv3/pages.js

@@ -6,6 +6,7 @@ import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '../../middlewares/exclude-read-only-user';
 import { isV5ConversionError } from '../../models/vo/v5-conversion-error';
 
 import { ErrorV3 } from '@growi/core';
@@ -292,7 +293,7 @@ module.exports = (crowi) => {
    *          409:
    *            description: page path is already existed
    */
-  router.post('/', accessTokenParser, loginRequiredStrictly, addActivity, validator.createPage, apiV3FormValidator, async(req, res) => {
+  router.post('/', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.createPage, apiV3FormValidator, async(req, res) => {
     const {
       body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags,
     } = req.body;
@@ -504,7 +505,7 @@ module.exports = (crowi) => {
    *          409:
    *            description: page path is already existed
    */
-  router.put('/rename', accessTokenParser, loginRequiredStrictly, validator.renamePage, apiV3FormValidator, async(req, res) => {
+  router.put('/rename', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, validator.renamePage, apiV3FormValidator, async(req, res) => {
     const { pageId, revisionId } = req.body;
 
     let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
@@ -575,35 +576,36 @@ module.exports = (crowi) => {
     }
   });
 
-  router.post('/resume-rename', accessTokenParser, loginRequiredStrictly, validator.resumeRenamePage, apiV3FormValidator, async(req, res) => {
+  router.post('/resume-rename', accessTokenParser, loginRequiredStrictly, validator.resumeRenamePage, apiV3FormValidator,
+    async(req, res) => {
 
-    const { pageId } = req.body;
-    const { user } = req;
+      const { pageId } = req.body;
+      const { user } = req;
 
-    // The user has permission to resume rename operation if page is returned.
-    const page = await Page.findByIdAndViewer(pageId, user, null, true);
-    if (page == null) {
-      const msg = 'The operation is forbidden for this user';
-      const code = 'forbidden-user';
-      return res.apiv3Err(new ErrorV3(msg, code), 403);
-    }
+      // The user has permission to resume rename operation if page is returned.
+      const page = await Page.findByIdAndViewer(pageId, user, null, true);
+      if (page == null) {
+        const msg = 'The operation is forbidden for this user';
+        const code = 'forbidden-user';
+        return res.apiv3Err(new ErrorV3(msg, code), 403);
+      }
 
-    const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
-    if (pageOp == null) {
-      const msg = 'PageOperation document for Rename Sub operation not found.';
-      const code = 'document_not_found';
-      return res.apiv3Err(new ErrorV3(msg, code), 404);
-    }
+      const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
+      if (pageOp == null) {
+        const msg = 'PageOperation document for Rename Sub operation not found.';
+        const code = 'document_not_found';
+        return res.apiv3Err(new ErrorV3(msg, code), 404);
+      }
 
-    try {
-      await crowi.pageService.resumeRenameSubOperation(page, pageOp);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err, 500);
-    }
-    return res.apiv3();
-  });
+      try {
+        await crowi.pageService.resumeRenameSubOperation(page, pageOp);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+      return res.apiv3();
+    });
 
   /**
    * @swagger
@@ -616,7 +618,7 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to remove all trash pages
    */
-  router.delete('/empty-trash', accessTokenParser, loginRequired, addActivity, apiV3FormValidator, async(req, res) => {
+  router.delete('/empty-trash', accessTokenParser, loginRequired, excludeReadOnlyUser, addActivity, apiV3FormValidator, async(req, res) => {
     const options = {};
 
     const pagesInTrash = await crowi.pageService.findAllTrashPages(req.user);
@@ -746,61 +748,62 @@ module.exports = (crowi) => {
    *          500:
    *            description: Internal server error.
    */
-  router.post('/duplicate', accessTokenParser, loginRequiredStrictly, addActivity, validator.duplicatePage, apiV3FormValidator, async(req, res) => {
-    const { pageId, isRecursively } = req.body;
+  router.post('/duplicate', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.duplicatePage, apiV3FormValidator,
+    async(req, res) => {
+      const { pageId, isRecursively } = req.body;
 
-    const newPagePath = pathUtils.normalizePath(req.body.pageNameInput);
+      const newPagePath = pathUtils.normalizePath(req.body.pageNameInput);
 
-    const isCreatable = isCreatablePage(newPagePath);
-    if (!isCreatable) {
-      return res.apiv3Err(new ErrorV3('This page path is invalid', 'invalid_path'), 400);
-    }
+      const isCreatable = isCreatablePage(newPagePath);
+      if (!isCreatable) {
+        return res.apiv3Err(new ErrorV3('This page path is invalid', 'invalid_path'), 400);
+      }
 
-    // check page existence
-    const isExist = (await Page.count({ path: newPagePath })) > 0;
-    if (isExist) {
-      return res.apiv3Err(new ErrorV3(`Page exists '${newPagePath})'`, 'already_exists'), 409);
-    }
+      // check page existence
+      const isExist = (await Page.count({ path: newPagePath })) > 0;
+      if (isExist) {
+        return res.apiv3Err(new ErrorV3(`Page exists '${newPagePath})'`, 'already_exists'), 409);
+      }
 
-    const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
+      const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
-    const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
-    if (page == null || isEmptyAndNotRecursively) {
-      res.code = 'Page is not found';
-      logger.error('Failed to find the pages');
-      return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
-    }
+      const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
+      if (page == null || isEmptyAndNotRecursively) {
+        res.code = 'Page is not found';
+        logger.error('Failed to find the pages');
+        return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
+      }
 
-    const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively);
-    const result = { page: serializePageSecurely(newParentPage) };
+      const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively);
+      const result = { page: serializePageSecurely(newParentPage) };
 
-    // copy the page since it's used and updated in crowi.pageService.duplicate
-    const copyPage = { ...page };
-    copyPage.path = newPagePath;
-    try {
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, copyPage, req.user);
-    }
-    catch (err) {
-      logger.error('Create grobal notification failed', err);
-    }
+      // copy the page since it's used and updated in crowi.pageService.duplicate
+      const copyPage = { ...page };
+      copyPage.path = newPagePath;
+      try {
+        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, copyPage, req.user);
+      }
+      catch (err) {
+        logger.error('Create grobal notification failed', err);
+      }
 
-    // create subscription (parent page only)
-    try {
-      await crowi.inAppNotificationService.createSubscription(req.user.id, newParentPage._id, subscribeRuleNames.PAGE_CREATE);
-    }
-    catch (err) {
-      logger.error('Failed to create subscription document', err);
-    }
+      // create subscription (parent page only)
+      try {
+        await crowi.inAppNotificationService.createSubscription(req.user.id, newParentPage._id, subscribeRuleNames.PAGE_CREATE);
+      }
+      catch (err) {
+        logger.error('Failed to create subscription document', err);
+      }
 
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: SupportedAction.ACTION_PAGE_DUPLICATE,
-    };
-    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+      const parameters = {
+        targetModel: SupportedTargetModel.MODEL_PAGE,
+        target: page,
+        action: SupportedAction.ACTION_PAGE_DUPLICATE,
+      };
+      activityEvent.emit('update', res.locals.activity._id, parameters, page);
 
-    return res.apiv3(result);
-  });
+      return res.apiv3(result);
+    });
 
   /**
    * @swagger
@@ -851,7 +854,7 @@ module.exports = (crowi) => {
 
   });
 
-  router.post('/delete', accessTokenParser, loginRequiredStrictly, validator.deletePages, apiV3FormValidator, async(req, res) => {
+  router.post('/delete', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, validator.deletePages, apiV3FormValidator, async(req, res) => {
     const {
       pageIdToRevisionIdMap, isCompletely, isRecursively, isAnyoneWithTheLink,
     } = req.body;
@@ -913,7 +916,7 @@ module.exports = (crowi) => {
 
 
   // eslint-disable-next-line max-len
-  router.post('/convert-pages-by-path', accessTokenParser, loginRequiredStrictly, adminRequired, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
+  router.post('/convert-pages-by-path', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, adminRequired, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
     const { convertPath } = req.body;
 
     // Convert by path
@@ -935,7 +938,7 @@ module.exports = (crowi) => {
   });
 
   // eslint-disable-next-line max-len
-  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
+  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, excludeReadOnlyUser, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
     const { pageIds: _pageIds, isRecursively } = req.body;
 
     // Convert by pageIds

+ 5 - 6
apps/app/src/server/routes/apiv3/share-links.js

@@ -4,11 +4,10 @@ import { ErrorV3 } from '@growi/core';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
 import loggerFactory from '~/utils/logger';
 
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-
-
 const logger = loggerFactory('growi:routes:apiv3:share-links');
 
 const express = require('express');
@@ -135,7 +134,7 @@ module.exports = (crowi) => {
    *            description: Succeeded to create one share link
    */
 
-  router.post('/', loginRequired, linkSharingRequired, addActivity, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
+  router.post('/', loginRequired, excludeReadOnlyUser, linkSharingRequired, addActivity, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
     const { relatedPage, expiredAt, description } = req.body;
 
     const page = await Page.findByIdAndViewer(relatedPage, req.user);
@@ -187,7 +186,7 @@ module.exports = (crowi) => {
   *          200:
   *            description: Succeeded to delete o all share links related one page
   */
-  router.delete('/', loginRequired, addActivity, validator.deleteShareLinks, apiV3FormValidator, async(req, res) => {
+  router.delete('/', loginRequired, excludeReadOnlyUser, addActivity, validator.deleteShareLinks, apiV3FormValidator, async(req, res) => {
     const { relatedPage } = req.query;
     const page = await Page.findByIdAndViewer(relatedPage, req.user);
 
@@ -261,7 +260,7 @@ module.exports = (crowi) => {
   *          200:
   *            description: Succeeded to delete one share link
   */
-  router.delete('/:id', loginRequired, addActivity, validator.deleteShareLink, apiV3FormValidator, async(req, res) => {
+  router.delete('/:id', loginRequired, excludeReadOnlyUser, addActivity, validator.deleteShareLink, apiV3FormValidator, async(req, res) => {
     const { id } = req.params;
     const { user } = req;
 

+ 19 - 18
apps/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -507,28 +507,29 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to delete access tokens for slack
    */
-  router.delete('/slack-app-integrations/:id', validator.deleteIntegration, apiV3FormValidator, addActivity, async(req, res) => {
-    const { id } = req.params;
+  router.delete('/slack-app-integrations/:id', loginRequiredStrictly, adminRequired, validator.deleteIntegration, apiV3FormValidator, addActivity,
+    async(req, res) => {
+      const { id } = req.params;
 
-    try {
-      const response = await SlackAppIntegration.findOneAndDelete({ _id: id });
+      try {
+        const response = await SlackAppIntegration.findOneAndDelete({ _id: id });
 
-      // update primary
-      const countOfPrimary = await SlackAppIntegration.countDocuments({ isPrimary: true });
-      if (countOfPrimary === 0) {
-        await SlackAppIntegration.updateOne({}, { isPrimary: true });
-      }
+        // update primary
+        const countOfPrimary = await SlackAppIntegration.countDocuments({ isPrimary: true });
+        if (countOfPrimary === 0) {
+          await SlackAppIntegration.updateOne({}, { isPrimary: true });
+        }
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_WORKSPACE_DELETE });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_WORKSPACE_DELETE });
 
-      return res.apiv3({ response });
-    }
-    catch (error) {
-      const msg = 'Error occured in deleting access token for slack app tokens';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-slackAppTokens-failed'), 500);
-    }
-  });
+        return res.apiv3({ response });
+      }
+      catch (error) {
+        const msg = 'Error occured in deleting access token for slack app tokens';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'update-slackAppTokens-failed'), 500);
+      }
+    });
 
   router.put('/proxy-uri', loginRequiredStrictly, adminRequired, addActivity, validator.proxyUri, apiV3FormValidator, async(req, res) => {
     const { proxyUri } = req.body;

+ 18 - 17
apps/app/src/server/routes/index.js

@@ -6,6 +6,7 @@ import { middlewareFactory as rateLimiterFactory } from '~/features/rate-limiter
 import { generateAddActivityMiddleware } from '../middlewares/add-activity';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
 import { generateCertifyBrandLogoMiddleware } from '../middlewares/certify-brand-logo';
+import { excludeReadOnlyUser } from '../middlewares/exclude-read-only-user';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 import * as loginFormValidator from '../middlewares/login-form-validator';
@@ -126,27 +127,27 @@ module.exports = function(crowi, app) {
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   apiV1Router.get('/pages.list'          , accessTokenParser , loginRequired , page.api.list);
-  apiV1Router.post('/pages.update'       , accessTokenParser , loginRequiredStrictly , addActivity, page.api.update);
+  apiV1Router.post('/pages.update'       , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, page.api.update);
   apiV1Router.get('/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
   apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   apiV1Router.get('/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
-  apiV1Router.post('/pages.remove'       , loginRequiredStrictly , page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
-  apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
-  apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , page.api.unlink); // (Avoid from API Token)
-  apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, page.api.duplicate);
+  apiV1Router.post('/pages.remove'       , loginRequiredStrictly , excludeReadOnlyUser, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
+  apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , excludeReadOnlyUser, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
+  apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , excludeReadOnlyUser, page.api.unlink); // (Avoid from API Token)
+  apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, page.api.duplicate);
   apiV1Router.get('/tags.list'           , accessTokenParser, loginRequired, tag.api.list);
   apiV1Router.get('/tags.search'         , accessTokenParser, loginRequired, tag.api.search);
-  apiV1Router.post('/tags.update'        , accessTokenParser, loginRequiredStrictly, addActivity, tag.api.update);
+  apiV1Router.post('/tags.update'        , accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, tag.api.update);
   apiV1Router.get('/comments.get'        , accessTokenParser , loginRequired , comment.api.get);
-  apiV1Router.post('/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , addActivity, comment.api.add);
-  apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , addActivity, comment.api.update);
-  apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , addActivity, comment.api.remove);
-
-  apiV1Router.post('/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,addActivity ,attachment.api.add);
-  apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,attachment.api.uploadProfileImage);
-  apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , addActivity ,attachment.api.remove);
-  apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , attachment.api.removeProfileImage);
+  apiV1Router.post('/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.add);
+  apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.update);
+  apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.remove);
+
+  apiV1Router.post('/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachment.api.add);
+  apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, attachment.api.uploadProfileImage);
+  apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachment.api.remove);
+  apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, attachment.api.removeProfileImage);
   apiV1Router.get('/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
 
   // API v1
@@ -165,9 +166,9 @@ module.exports = function(crowi, app) {
 
   app.get('/_hackmd/load-agent'          , hackmd.loadAgent);
   app.get('/_hackmd/load-styles'         , hackmd.loadStyles);
-  app.post('/_api/hackmd.integrate'      , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.integrate);
-  app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.discard);
-  app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.saveOnHackmd);
+  app.post('/_api/hackmd.integrate'      , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, hackmd.validateForApi, hackmd.integrate);
+  app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, hackmd.validateForApi, hackmd.discard);
+  app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, hackmd.validateForApi, hackmd.saveOnHackmd);
 
   app.use('/forgot-password', express.Router()
     .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))

+ 48 - 0
apps/app/test/unit/middlewares/exclude-read-only-user.test.ts

@@ -0,0 +1,48 @@
+import { ErrorV3 } from '@growi/core';
+
+import { excludeReadOnlyUser } from '../../../src/server/middlewares/exclude-read-only-user';
+
+describe('excludeReadOnlyUser', () => {
+  let req;
+  let res;
+  let next;
+
+  beforeEach(() => {
+    req = {
+      user: {},
+    };
+    res = {
+      apiv3Err: jest.fn(),
+    };
+    next = jest.fn();
+  });
+
+  test('should call next if user is not found', () => {
+    req.user = null;
+
+    excludeReadOnlyUser(req, res, next);
+
+    expect(next).toBeCalled();
+    expect(res.apiv3Err).not.toBeCalled();
+  });
+
+  test('should call next if user is not read only', () => {
+    req.user.readOnly = false;
+
+    excludeReadOnlyUser(req, res, next);
+
+    expect(next).toBeCalled();
+    expect(res.apiv3Err).not.toBeCalled();
+  });
+
+  test('should return error response if user is read only', () => {
+    req.user.readOnly = true;
+
+    excludeReadOnlyUser(req, res, next);
+
+    expect(next).not.toBeCalled();
+    expect(res.apiv3Err).toBeCalledWith(
+      new ErrorV3('This user is read only user', 'validatioin_failed'),
+    );
+  });
+});