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

Merge pull request #9019 from weseek/imprv/serializers

imprv: Serializers for User model and Attachment model
Yuki Takei 1 год назад
Родитель
Сommit
a1df330657
38 измененных файлов с 300 добавлено и 221 удалено
  1. 5 0
      .changeset/metal-donkeys-collect.md
  2. 1 1
      apps/app/package.json
  3. 1 1
      apps/app/src/client/components/Admin/AuditLog/ActivityTable.tsx
  4. 5 5
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts
  5. 2 1
      apps/app/src/server/middlewares/access-token-parser.js
  6. 1 1
      apps/app/src/server/models/serializers/bookmark-serializer.js
  7. 0 1
      apps/app/src/server/models/serializers/index.ts
  8. 1 1
      apps/app/src/server/models/serializers/page-serializer.js
  9. 1 1
      apps/app/src/server/models/serializers/revision-serializer.js
  10. 1 1
      apps/app/src/server/models/serializers/user-group-relation-serializer.js
  11. 0 35
      apps/app/src/server/models/serializers/user-serializer.js
  12. 1 2
      apps/app/src/server/models/user.js
  13. 12 10
      apps/app/src/server/routes/apiv3/activity.ts
  14. 4 5
      apps/app/src/server/routes/apiv3/attachment.js
  15. 2 1
      apps/app/src/server/routes/apiv3/bookmarks.js
  16. 1 1
      apps/app/src/server/routes/apiv3/forgot-password.js
  17. 31 17
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  18. 2 3
      apps/app/src/server/routes/apiv3/page/create-page.ts
  19. 3 3
      apps/app/src/server/routes/apiv3/page/update-page.ts
  20. 2 2
      apps/app/src/server/routes/apiv3/pages/index.js
  21. 2 3
      apps/app/src/server/routes/apiv3/revisions.js
  22. 2 3
      apps/app/src/server/routes/apiv3/user-group-relation.js
  23. 6 9
      apps/app/src/server/routes/apiv3/user-group.js
  24. 7 9
      apps/app/src/server/routes/apiv3/users.js
  25. 1 3
      apps/app/src/server/routes/attachment/api.js
  26. 3 2
      apps/app/src/server/routes/comment.js
  27. 6 12
      apps/app/src/server/service/search-delegator/private-legacy-pages.ts
  28. 1 1
      apps/app/src/server/service/search.ts
  29. 4 0
      packages/core/package.json
  30. 11 1
      packages/core/src/interfaces/common.ts
  31. 40 0
      packages/core/src/models/serializers/attachment-serializer.ts
  32. 2 0
      packages/core/src/models/serializers/index.ts
  33. 37 0
      packages/core/src/models/serializers/user-serializer.ts
  34. 3 1
      packages/remark-attachment-refs/package.json
  35. 5 1
      packages/remark-attachment-refs/src/client/components/RefsImg.tsx
  36. 10 5
      packages/remark-attachment-refs/src/client/stores/refs.tsx
  37. 78 75
      packages/remark-attachment-refs/src/server/routes/refs.ts
  38. 6 4
      yarn.lock

+ 5 - 0
.changeset/metal-donkeys-collect.md

@@ -0,0 +1,5 @@
+---
+'@growi/core': minor
+---
+
+Transplant and re-implement serializers for User and Attachment

+ 1 - 1
apps/app/package.json

@@ -209,7 +209,7 @@
     "usehooks-ts": "^2.6.0",
     "validator": "^13.7.0",
     "ws": "^8.17.1",
-    "xss": "^1.0.14",
+    "xss": "^1.0.15",
     "y-mongodb-provider": "^0.2.0",
     "y-socket.io": "^1.1.3",
     "yjs": "^13.6.18"

+ 1 - 1
apps/app/src/client/components/Admin/AuditLog/ActivityTable.tsx

@@ -15,7 +15,7 @@ type Props = {
   activityList: IActivityHasId[]
 }
 
-const formatDate = (date) => {
+const formatDate = (date: Date): string => {
   return format(new Date(date), 'yyyy/MM/dd HH:mm:ss');
 };
 

+ 5 - 5
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts

@@ -1,19 +1,19 @@
 import { ErrorV3 } from '@growi/core/dist/models';
-import { Router, Request } from 'express';
+import type { Router, Request } from 'express';
 
-import { IExternalUserGroupRelationHasId } from '~/features/external-user-group/interfaces/external-user-group';
+import type { IExternalUserGroupRelationHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import Crowi from '~/server/crowi';
+import type Crowi from '~/server/crowi';
+import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
-import { ApiV3Response } from '../../../../../server/routes/apiv3/interfaces/apiv3-response';
 
 const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
 const { query } = require('express-validator');
 
-const { serializeUserGroupRelationSecurely } = require('~/server/models/serializers/user-group-relation-serializer');
 
 const router = express.Router();
 

+ 2 - 1
apps/app/src/server/middlewares/access-token-parser.js

@@ -1,6 +1,7 @@
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+
 import loggerFactory from '~/utils/logger';
 
-const { serializeUserSecurely } = require('../models/serializers/user-serializer');
 
 const logger = loggerFactory('growi:middleware:access-token-parser');
 

+ 1 - 1
apps/app/src/server/models/serializers/bookmark-serializer.js

@@ -1,4 +1,4 @@
-const { serializePageSecurely } = require('./page-serializer');
+import { serializePageSecurely } from './page-serializer';
 
 function serializeInsecurePageAttributes(bookmark) {
   if (bookmark.page != null && bookmark.page._id != null) {

+ 0 - 1
apps/app/src/server/models/serializers/index.ts

@@ -2,4 +2,3 @@ export * from './bookmark-serializer';
 export * from './page-serializer';
 export * from './revision-serializer';
 export * from './user-group-relation-serializer';
-export * from './user-serializer';

+ 1 - 1
apps/app/src/server/models/serializers/page-serializer.js

@@ -1,4 +1,4 @@
-const { serializeUserSecurely } = require('./user-serializer');
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 
 function depopulate(page, attributeName) {
   // revert the ObjectID

+ 1 - 1
apps/app/src/server/models/serializers/revision-serializer.js

@@ -1,4 +1,4 @@
-const { serializeUserSecurely } = require('./user-serializer');
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 
 function serializeInsecureUserAttributes(revision) {
   if (revision.author != null && revision.author._id != null) {

+ 1 - 1
apps/app/src/server/models/serializers/user-group-relation-serializer.js

@@ -1,4 +1,4 @@
-const { serializeUserSecurely } = require('./user-serializer');
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 
 function serializeInsecureUserAttributes(userGroupRelation) {
   if (userGroupRelation.relatedUser != null && userGroupRelation.relatedUser._id != null) {

+ 0 - 35
apps/app/src/server/models/serializers/user-serializer.js

@@ -1,35 +0,0 @@
-const mongoose = require('mongoose');
-
-
-export function omitInsecureAttributes(user) {
-  // omit password
-  delete user.password;
-  // omit apiToken
-  delete user.apiToken;
-
-  // omit email
-  if (!user.isEmailPublished) {
-    delete user.email;
-  }
-  return user;
-}
-
-export function serializeUserSecurely(user) {
-  const User = mongoose.model('User');
-
-  // return when it is not a user object
-  if (user == null || !(user instanceof User)) {
-    return user;
-  }
-
-  let serialized = user;
-
-  // invoke toObject if page is a model instance
-  if (user.toObject != null) {
-    serialized = user.toObject();
-  }
-
-  omitInsecureAttributes(serialized);
-
-  return serialized;
-}

+ 1 - 2
apps/app/src/server/models/user.js

@@ -1,4 +1,5 @@
 /* eslint-disable no-use-before-define */
+import { omitInsecureAttributes } from '@growi/core/dist/models/serializers';
 import { pagePathUtils } from '@growi/core/dist/utils';
 
 import { i18n } from '^/config/next-i18next.config';
@@ -15,8 +16,6 @@ const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 
-const { omitInsecureAttributes } = require('./serializers/user-serializer');
-
 const logger = loggerFactory('growi:models:user');
 
 module.exports = function(crowi) {

+ 12 - 10
apps/app/src/server/routes/apiv3/activity.ts

@@ -1,16 +1,17 @@
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { parseISO, addMinutes, isValid } from 'date-fns';
-import express, { Request, Router } from 'express';
+import type { Request, Router } from 'express';
+import express from 'express';
 import { query } from 'express-validator';
 
-import { IActivity, ISearchFilter } from '~/interfaces/activity';
+import type { IActivity, ISearchFilter } from '~/interfaces/activity';
 import Activity from '~/server/models/activity';
 import loggerFactory from '~/utils/logger';
 
-import Crowi from '../../crowi';
+import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import { serializeUserSecurely } from '../../models/serializers/user-serializer';
 
-import { ApiV3Response } from './interfaces/apiv3-response';
+import type { ApiV3Response } from './interfaces/apiv3-response';
 
 
 const logger = loggerFactory('growi:routes:apiv3:activity');
@@ -96,6 +97,7 @@ module.exports = (crowi: Crowi): Router => {
       const paginateResult = await Activity.paginate(
         query,
         {
+          lean: true,
           limit,
           offset,
           sort: { createdAt: -1 },
@@ -103,12 +105,12 @@ module.exports = (crowi: Crowi): Router => {
         },
       );
 
-      const User = crowi.model('User');
       const serializedDocs = paginateResult.docs.map((doc: IActivity) => {
-        if (doc.user != null && doc.user instanceof User) {
-          doc.user = serializeUserSecurely(doc.user);
-        }
-        return doc;
+        const { user, ...rest } = doc;
+        return {
+          user: serializeUserSecurely(user),
+          ...rest,
+        };
       });
 
       const serializedPaginationResult = {

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

@@ -1,10 +1,13 @@
 import { ErrorV3 } from '@growi/core/dist/models';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import express from 'express';
 import multer from 'multer';
 import autoReap from 'multer-autoreap';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { AttachmentType } from '~/server/interfaces/attachment';
-import { Attachment } from '~/server/models';
+import { Attachment } from '~/server/models/attachment';
+import { serializePageSecurely, serializeRevisionSecurely } from '~/server/models/serializers';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -14,16 +17,12 @@ import { excludeReadOnlyUser } from '../../middlewares/exclude-read-only-user';
 
 
 const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
-const express = require('express');
 
 const router = express.Router();
 const {
   query, param, body,
 } = require('express-validator');
 
-const { serializePageSecurely } = require('../../models/serializers/page-serializer');
-const { serializeRevisionSecurely } = require('../../models/serializers/revision-serializer');
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 /**
  * @swagger

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

@@ -1,3 +1,5 @@
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
@@ -12,7 +14,6 @@ const logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-
 const express = require('express');
 const { body, query, param } = require('express-validator');
 
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 const router = express.Router();
 

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

@@ -1,4 +1,5 @@
 import { ErrorV3 } from '@growi/core/dist/models';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { format, subSeconds } from 'date-fns';
 
 import { SupportedAction } from '~/interfaces/activity';
@@ -17,7 +18,6 @@ const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-dis
 const express = require('express');
 const { body } = require('express-validator');
 
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 const router = express.Router();
 

+ 31 - 17
apps/app/src/server/routes/apiv3/in-app-notification.ts

@@ -1,12 +1,14 @@
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import express from 'express';
+
 import { SupportedAction } from '~/interfaces/activity';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 
+import type { IInAppNotification } from '../../../interfaces/in-app-notification';
 
-import { IInAppNotification } from '../../../interfaces/in-app-notification';
-
-const express = require('express');
+import type { ApiV3Response } from './interfaces/apiv3-response';
 
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 const router = express.Router();
 
@@ -22,14 +24,18 @@ module.exports = (crowi) => {
 
   const activityEvent = crowi.event('activity');
 
-  router.get('/list', accessTokenParser, loginRequiredStrictly, async(req, res) => {
-    const user = req.user;
+  router.get('/list', accessTokenParser, loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+    // user must be set by loginRequiredStrictly
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const user = req.user!;
 
-    const limit = parseInt(req.query.limit) || 10;
+    const limit = req.query.limit != null
+      ? parseInt(req.query.limit.toString()) || 10
+      : 10;
 
     let offset = 0;
-    if (req.query.offset) {
-      offset = parseInt(req.query.offset, 10);
+    if (req.query.offset != null) {
+      offset = parseInt(req.query.offset.toString(), 10);
     }
 
     const queryOptions = {
@@ -73,10 +79,13 @@ module.exports = (crowi) => {
     return res.apiv3(serializedPaginationResult);
   });
 
-  router.get('/status', accessTokenParser, loginRequiredStrictly, async(req, res) => {
-    const userId = req.user._id;
+  router.get('/status', accessTokenParser, loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+    // user must be set by loginRequiredStrictly
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const user = req.user!;
+
     try {
-      const count = await inAppNotificationService.getUnreadCountByUser(userId);
+      const count = await inAppNotificationService.getUnreadCountByUser(user._id);
       return res.apiv3({ count });
     }
     catch (err) {
@@ -84,7 +93,7 @@ module.exports = (crowi) => {
     }
   });
 
-  router.post('/read', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.post('/read', accessTokenParser, loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
     const user = req.user;
 
     try {
@@ -96,8 +105,11 @@ module.exports = (crowi) => {
     }
   });
 
-  router.post('/open', accessTokenParser, loginRequiredStrictly, async(req, res) => {
-    const user = req.user;
+  router.post('/open', accessTokenParser, loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+    // user must be set by loginRequiredStrictly
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const user = req.user!;
+
     const id = req.body.id;
 
     try {
@@ -110,8 +122,10 @@ module.exports = (crowi) => {
     }
   });
 
-  router.put('/all-statuses-open', accessTokenParser, loginRequiredStrictly, addActivity, async(req, res) => {
-    const user = req.user;
+  router.put('/all-statuses-open', accessTokenParser, loginRequiredStrictly, addActivity, async(req: CrowiRequest, res: ApiV3Response) => {
+    // user must be set by loginRequiredStrictly
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const user = req.user!;
 
     try {
       await inAppNotificationService.updateAllNotificationsAsOpened(user);

+ 2 - 3
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -16,11 +16,10 @@ import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import type { IOptionsForCreate } from '~/interfaces/page';
 import type Crowi from '~/server/crowi';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
-import {
-  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely,
-} from '~/server/models';
+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 { configManager } from '~/server/service/config-manager';
 import { getTranslation } from '~/server/service/i18next';
 import loggerFactory from '~/utils/logger';

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

@@ -3,6 +3,7 @@ import type {
   IPage, IRevisionHasId, IUserHasId,
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
@@ -13,10 +14,9 @@ import { type IApiv3PageUpdateParams, PageUpdateErrorCode } from '~/interfaces/a
 import type { IOptionsForUpdate } from '~/interfaces/page';
 import type Crowi from '~/server/crowi';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
-import {
-  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely, serializeUserSecurely,
-} from '~/server/models';
+import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageDocument, PageModel } from '~/server/models/page';
+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';

+ 2 - 2
apps/app/src/server/routes/apiv3/pages/index.js

@@ -1,6 +1,7 @@
 
 import { PageGrant } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { isCreatablePage, isTrashPage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
 import { normalizePath, addHeadingSlash } from '@growi/core/dist/utils/path-utils';
 import express from 'express';
@@ -8,7 +9,7 @@ import { body, query } from 'express-validator';
 
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
-import { GlobalNotificationSettingEvent } from '~/server/models';
+import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import PageTagRelation from '~/server/models/page-tag-relation';
 import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
@@ -17,7 +18,6 @@ import { generateAddActivityMiddleware } from '../../../middlewares/add-activity
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
 import { serializePageSecurely } from '../../../models/serializers/page-serializer';
-import { serializeUserSecurely } from '../../../models/serializers/user-serializer';
 import { isV5ConversionError } from '../../../models/vo/v5-conversion-error';
 
 

+ 2 - 3
apps/app/src/server/routes/apiv3/revisions.js

@@ -1,4 +1,6 @@
 import { ErrorV3 } from '@growi/core/dist/models';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import express from 'express';
 
 import { Revision } from '~/server/models/revision';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
@@ -8,11 +10,8 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:pages');
 
-const express = require('express');
 const { query, param } = require('express-validator');
 
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-
 const router = express.Router();
 
 /**

+ 2 - 3
apps/app/src/server/routes/apiv3/user-group-relation.js

@@ -1,15 +1,14 @@
 import { ErrorV3 } from '@growi/core/dist/models';
+import express from 'express';
 
+import { serializeUserGroupRelationSecurely } from '~/server/models/serializers';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
 
-const express = require('express');
 const { query } = require('express-validator');
 
-const { serializeUserGroupRelationSecurely } = require('../../models/serializers/user-group-relation-serializer');
-
 const router = express.Router();
 
 const validator = {};

+ 6 - 9
apps/app/src/server/routes/apiv3/user-group.js

@@ -1,12 +1,17 @@
 import { GroupType } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
-
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import express from 'express';
+import {
+  body, param, query, sanitizeQuery,
+} from 'express-validator';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import UserGroup from '~/server/models/user-group';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+import { toPagingLimit, toPagingOffset } from '~/server/util/express-validator/sanitizer';
 import { generalXssFilter } from '~/services/general-xss-filter';
 import loggerFactory from '~/utils/logger';
 
@@ -16,16 +21,8 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:user-group'); // eslint-disable-line no-unused-vars
 
-const express = require('express');
-
 const router = express.Router();
 
-const { body, param, query } = require('express-validator');
-const { sanitizeQuery } = require('express-validator');
-
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-const { toPagingLimit, toPagingOffset } = require('../../util/express-validator/sanitizer');
-
 
 /**
  * @swagger

+ 7 - 9
apps/app/src/server/routes/apiv3/users.js

@@ -1,11 +1,18 @@
 
+import path from 'path';
+
 import { ErrorV3 } from '@growi/core/dist/models';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
+import express from 'express';
+import { body, query } from 'express-validator';
+import { isEmail } from 'validator';
 
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { SupportedAction } from '~/interfaces/activity';
 import Activity from '~/server/models/activity';
 import ExternalAccount from '~/server/models/external-account';
+import { serializePageSecurely } from '~/server/models/serializers';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import { configManager } from '~/server/service/config-manager';
 import { deleteCompletelyUserHomeBySystem } from '~/server/service/page/delete-completely-user-home-by-system';
@@ -16,17 +23,8 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:users');
 
-const path = require('path');
-
-const express = require('express');
-
 const router = express.Router();
 
-const { body, query } = require('express-validator');
-const { isEmail } = require('validator');
-
-const { serializePageSecurely } = require('../../models/serializers/page-serializer');
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 const PAGE_ITEMS = 50;
 

+ 1 - 3
apps/app/src/server/routes/attachment/api.js

@@ -1,11 +1,9 @@
-import { isCreatablePage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 
-import { Attachment, serializePageSecurely, serializeRevisionSecurely } from '../../models';
-
+import { Attachment } from '../../models/attachment';
 /* eslint-disable no-use-before-define */
 
 

+ 3 - 2
apps/app/src/server/routes/comment.js

@@ -1,9 +1,11 @@
 
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+
 import { Comment, CommentEvent, commentEvent } from '~/features/comment/server';
 import { SupportedAction, SupportedTargetModel, SupportedEventModel } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
-import { GlobalNotificationSettingEvent } from '../models';
+import { GlobalNotificationSettingEvent } from '../models/GlobalNotificationSetting';
 import { preNotifyService } from '../service/pre-notify';
 
 /**
@@ -12,7 +14,6 @@ import { preNotifyService } from '../service/pre-notify';
  *    name: Comments
  */
 
-const { serializeUserSecurely } = require('../models/serializers/user-serializer');
 
 /**
  * @swagger

+ 6 - 12
apps/app/src/server/service/search-delegator/private-legacy-pages.ts

@@ -2,14 +2,14 @@ import type { IPage } from '@growi/core';
 import mongoose from 'mongoose';
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
-import { ISearchResult } from '~/interfaces/search';
-import { PageModel, PageDocument, PageQueryBuilder } from '~/server/models/page';
+import type { ISearchResult } from '~/interfaces/search';
+import type { PageModel, PageDocument, PageQueryBuilder } from '~/server/models/page';
+import { serializePageSecurely } from '~/server/models/serializers';
 
-import {
+import type {
   QueryTerms, MongoTermsKey,
   SearchableData, SearchDelegator, UnavailableTermsKey, MongoQueryTerms,
 } from '../../interfaces/search';
-import { serializeUserSecurely } from '../../models/serializers/user-serializer';
 
 
 const AVAILABLE_KEYS = ['match', 'not_match', 'prefix', 'not_prefix'];
@@ -47,21 +47,15 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage, MongoTermsKe
 
     const total = await countQueryBuilder.query.count();
 
-    const _pages: PageDocument[] = await findQueryBuilder
+    const pages: PageDocument[] = await findQueryBuilder
       .addConditionToPagenate(offset, limit)
       .query
       .populate('creator')
       .populate('lastUpdateUser')
       .exec();
 
-    const pages = _pages.map((page) => {
-      page.creator = serializeUserSecurely(page.creator);
-      page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
-      return page;
-    });
-
     return {
-      data: pages,
+      data: pages.map(page => serializePageSecurely(page)),
       meta: {
         total,
         hitsCount: pages.length,

+ 1 - 1
apps/app/src/server/service/search.ts

@@ -1,4 +1,5 @@
 import type { IPageHasId } from '@growi/core';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import mongoose from 'mongoose';
 import { FilterXSS } from 'xss';
 
@@ -13,7 +14,6 @@ import type {
 } from '../interfaces/search';
 import NamedQuery from '../models/named-query';
 import type { PageModel } from '../models/page';
-import { serializeUserSecurely } from '../models/serializers/user-serializer';
 import { SearchError } from '../models/vo/search-error';
 import { hasIntersection } from '../util/compare-objectId';
 

+ 4 - 0
packages/core/package.json

@@ -30,6 +30,10 @@
       "import": "./dist/models/index.js",
       "require": "./dist/models/index.cjs"
     },
+    "./dist/models/serializers": {
+      "import": "./dist/models/serializers/index.js",
+      "require": "./dist/models/serializers/index.cjs"
+    },
     "./dist/remark-plugins": {
       "import": "./dist/remark-plugins/index.js",
       "require": "./dist/remark-plugins/index.cjs"

+ 11 - 1
packages/core/src/interfaces/common.ts

@@ -2,9 +2,10 @@
  * Common types and interfaces
  */
 
-
 import type { Types } from 'mongoose';
 
+import { isValidObjectId } from '../utils/objectid-utils';
+
 type ObjectId = Types.ObjectId;
 
 // Foreign key field
@@ -12,6 +13,15 @@ export type Ref<T> = string | ObjectId | T & { _id: string | ObjectId };
 
 export type Nullable<T> = T | null | undefined;
 
+export const isRef = <T>(obj: unknown): obj is Ref<T> => {
+  return obj != null
+    && (
+      (typeof obj === 'string' && isValidObjectId(obj))
+        || (typeof obj === 'object' && '_bsontype' in obj && obj._bsontype === 'ObjectID')
+        || (typeof obj === 'object' && '_id' in obj)
+    );
+};
+
 export const isPopulated = <T>(ref: Ref<T>): ref is T & { _id: string | ObjectId } => {
   return ref != null
     && typeof ref !== 'string'

+ 40 - 0
packages/core/src/models/serializers/attachment-serializer.ts

@@ -0,0 +1,40 @@
+import { Document } from 'mongoose';
+
+import type { IAttachment, IUser } from '~/interfaces';
+
+import { isPopulated, isRef, type Ref } from '../../interfaces/common';
+
+import { serializeUserSecurely, type IUserSerializedSecurely } from './user-serializer';
+
+export type IAttachmentSerializedSecurely<A extends IAttachment> = Omit<A, 'creator'> & { creator?: Ref<IUserSerializedSecurely<IUser>> };
+
+const omitInsecureAttributes = <A extends IAttachment>(attachment: A): IAttachmentSerializedSecurely<A> => {
+
+  const leanDoc = (attachment instanceof Document)
+    ? attachment.toObject<A>()
+    : attachment;
+
+  const { creator, ...rest } = leanDoc;
+
+  const secureCreator = creator == null
+    ? undefined
+    : serializeUserSecurely(creator);
+
+  return {
+    creator: secureCreator,
+    ...rest,
+  };
+};
+
+
+export function serializeAttachmentSecurely<A extends IAttachment>(attachment?: A): IAttachmentSerializedSecurely<A>;
+export function serializeAttachmentSecurely<A extends IAttachment>(attachment?: Ref<A>): Ref<IAttachmentSerializedSecurely<A>>;
+export function serializeAttachmentSecurely<A extends IAttachment>(attachment?: A | Ref<A>)
+    : undefined | IAttachmentSerializedSecurely<A> | Ref<IAttachmentSerializedSecurely<A>> {
+
+  if (attachment == null) return attachment;
+
+  if (isRef(attachment) && !isPopulated(attachment)) return attachment;
+
+  return omitInsecureAttributes(attachment);
+}

+ 2 - 0
packages/core/src/models/serializers/index.ts

@@ -0,0 +1,2 @@
+export * from './user-serializer';
+export * from './attachment-serializer';

+ 37 - 0
packages/core/src/models/serializers/user-serializer.ts

@@ -0,0 +1,37 @@
+import { Document } from 'mongoose';
+
+import { isPopulated, isRef, type Ref } from '../../interfaces/common';
+import type { IUser } from '../../interfaces/user';
+
+export type IUserSerializedSecurely<U extends IUser> = Omit<U, 'password' | 'apiToken' | 'email'> & { email?: string };
+
+export const omitInsecureAttributes = <U extends IUser>(user: U): IUserSerializedSecurely<U> => {
+
+  const leanDoc = (user instanceof Document)
+    ? user.toObject<U>()
+    : user;
+
+  const {
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    password, apiToken, email, ...rest
+  } = leanDoc;
+
+  const secureUser: IUserSerializedSecurely<U> = rest;
+
+  // omit email
+  if (secureUser.isEmailPublished) {
+    secureUser.email = email;
+  }
+
+  return secureUser;
+};
+
+export function serializeUserSecurely<U extends IUser>(user?: U): IUserSerializedSecurely<U>;
+export function serializeUserSecurely<U extends IUser>(user?: Ref<U>): Ref<IUserSerializedSecurely<U>>;
+export function serializeUserSecurely<U extends IUser>(user?: U | Ref<U>): undefined | IUserSerializedSecurely<U> | Ref<IUserSerializedSecurely<U>> {
+  if (user == null) return user;
+
+  if (isRef(user) && !isPopulated(user)) return user;
+
+  return omitInsecureAttributes(user);
+}

+ 3 - 1
packages/remark-attachment-refs/package.json

@@ -49,10 +49,12 @@
     "@growi/ui": "link:../ui",
     "axios": "^0.24.0",
     "bunyan": "^1.8.15",
+    "express": "^4.19.2",
     "hast-util-select": "^5.0.5",
     "mongoose": "^6.11.3",
     "swr": "^2.0.3",
-    "universal-bunyan": "^0.9.2"
+    "universal-bunyan": "^0.9.2",
+    "xss": "^1.0.15"
   },
   "devDependencies": {
     "csstype": "^3.0.2",

+ 5 - 1
packages/remark-attachment-refs/src/client/components/RefsImg.tsx

@@ -50,7 +50,7 @@ export const RefsImgSubstance = React.memo(({
       width, height, maxWidth, maxHeight,
       display, grid, gridGap, noCarousel]);
 
-  const { data, error, isLoading } = useSWRxRefs(pagePath, prefix, {
+  const { data, error: axiosError, isLoading } = useSWRxRefs(pagePath, prefix, {
     depth,
     regexp,
     width,
@@ -64,6 +64,10 @@ export const RefsImgSubstance = React.memo(({
   }, isImmutable);
   const attachments = data != null ? data : [];
 
+  const error = axiosError != null
+    ? new Error(axiosError.response?.data ?? axiosError.message)
+    : undefined;
+
   return (
     <AttachmentList
       refsContext={refsContext}

+ 10 - 5
packages/remark-attachment-refs/src/client/stores/refs.tsx

@@ -1,6 +1,9 @@
-import { IAttachmentHasId } from '@growi/core';
+import type { IAttachmentHasId } from '@growi/core';
+import type { AxiosError } from 'axios';
 import axios from 'axios';
-import useSWR, { SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
+// eslint-disable-next-line camelcase
+import useSWR, { unstable_serialize } from 'swr';
 
 export const useSWRxRef = (
     pagePath: string, fileNameOrId: string, isImmutable?: boolean,
@@ -27,10 +30,12 @@ export const useSWRxRef = (
 
 export const useSWRxRefs = (
     pagePath: string, prefix?: string, options?: Record<string, string | undefined>, isImmutable?: boolean,
-): SWRResponse<IAttachmentHasId[], Error> => {
+): SWRResponse<IAttachmentHasId[], AxiosError<string>> => {
+  const serializedOptions = unstable_serialize(options);
+
   return useSWR(
-    ['/_api/attachment-refs/refs', pagePath, prefix, options, isImmutable],
-    ([endpoint, pagePath, prefix, options]) => {
+    ['/_api/attachment-refs/refs', pagePath, prefix, serializedOptions, isImmutable],
+    async([endpoint, pagePath, prefix]) => {
       return axios.get(endpoint, {
         params: {
           pagePath,

+ 78 - 75
packages/remark-attachment-refs/src/server/routes/refs.ts

@@ -1,84 +1,89 @@
-import type { IAttachment } from '@growi/core';
+import type { IPage, IUser, IAttachment } from '@growi/core';
+import { serializeAttachmentSecurely } from '@growi/core/dist/models/serializers';
 import { OptionParser } from '@growi/core/dist/remark-plugins';
-import { model } from 'mongoose';
+import type { Request } from 'express';
+import { Router } from 'express';
+import type { Model, HydratedDocument } from 'mongoose';
+import mongoose, { model, Types } from 'mongoose';
+import { FilterXSS } from 'xss';
 
 import loggerFactory from '../../utils/logger';
 
 const logger = loggerFactory('growi:remark-attachment-refs:routes:refs');
 
 
-const loginRequiredFallback = (req, res) => {
-  return res.status(403).send('login required');
-};
+function generateRegexp(expression: string): RegExp {
+  // https://regex101.com/r/uOrwqt/2
+  const matches = expression.match(/^\/(.+)\/(.*)?$/);
+
+  return (matches != null)
+    ? new RegExp(matches[1], matches[2])
+    : new RegExp(expression);
+}
+
+/**
+ * add depth condition that limit fetched pages
+ *
+ * @param {any} query
+ * @param {any} pagePath
+ * @param {any} optionsDepth
+ * @returns query
+ */
+function addDepthCondition(query, pagePath, optionsDepth) {
+  // when option strings is 'depth=', the option value is true
+  if (optionsDepth == null || optionsDepth === true) {
+    throw new Error('The value of depth option is invalid.');
+  }
 
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-export const routesFactory = (crowi): any => {
-  const express = crowi.require('express');
-  const mongoose = crowi.require('mongoose');
+  const range = OptionParser.parseRange(optionsDepth);
 
-  const loginRequired = crowi.require('../middlewares/login-required')(crowi, true, loginRequiredFallback);
-  const accessTokenParser = crowi.require('../middlewares/access-token-parser')(crowi);
-  const { serializeUserSecurely } = crowi.require('../models/serializers/user-serializer');
+  if (range == null) {
+    return query;
+  }
 
-  const router = express.Router();
+  const start = range.start;
+  const end = range.end;
 
-  const ObjectId = mongoose.Types.ObjectId;
+  if (start < 1 || end < 1) {
+    throw new Error(`specified depth is [${start}:${end}] : start and end are must be larger than 1`);
+  }
 
-  const User = crowi.model('User');
-  const Page = crowi.model('Page');
+  // count slash
+  const slashNum = pagePath.split('/').length - 1;
+  const depthStart = slashNum; // start is not affect to fetch page
+  const depthEnd = slashNum + end - 1;
 
-  const { PageQueryBuilder } = Page;
+  return query.and({
+    path: new RegExp(`^(\\/[^\\/]*){${depthStart},${depthEnd}}$`),
+  });
+}
 
-  function generateRegexp(expression: string): RegExp {
-    // https://regex101.com/r/uOrwqt/2
-    const matches = expression.match(/^\/(.+)\/(.*)?$/);
 
-    return (matches != null)
-      ? new RegExp(matches[1], matches[2])
-      : new RegExp(expression);
-  }
+type RequestWithUser = Request & { user: HydratedDocument<IUser> };
 
-  /**
-   * add depth condition that limit fetched pages
-   *
-   * @param {any} query
-   * @param {any} pagePath
-   * @param {any} optionsDepth
-   * @returns query
-   */
-  function addDepthCondition(query, pagePath, optionsDepth) {
-    // when option strings is 'depth=', the option value is true
-    if (optionsDepth == null || optionsDepth === true) {
-      throw new Error('The value of depth option is invalid.');
-    }
+const loginRequiredFallback = (req, res) => {
+  return res.status(403).send('login required');
+};
 
-    const range = OptionParser.parseRange(optionsDepth);
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const routesFactory = (crowi): any => {
 
-    if (range == null) {
-      return query;
-    }
+  const loginRequired = crowi.require('../middlewares/login-required')(crowi, true, loginRequiredFallback);
+  const accessTokenParser = crowi.require('../middlewares/access-token-parser')(crowi);
 
-    const start = range.start;
-    const end = range.end;
+  const router = Router();
 
-    if (start < 1 || end < 1) {
-      throw new Error(`specified depth is [${start}:${end}] : start and end are must be larger than 1`);
-    }
+  const ObjectId = Types.ObjectId;
 
-    // count slash
-    const slashNum = pagePath.split('/').length - 1;
-    const depthStart = slashNum; // start is not affect to fetch page
-    const depthEnd = slashNum + end - 1;
+  const Page = mongoose.model <HydratedDocument<IPage>, Model<any> & any>('Page');
 
-    return query.and({
-      path: new RegExp(`^(\\/[^\\/]*){${depthStart},${depthEnd}}$`),
-    });
-  }
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const { PageQueryBuilder } = Page as any;
 
   /**
    * return an Attachment model
    */
-  router.get('/ref', accessTokenParser, loginRequired, async(req, res) => {
+  router.get('/ref', accessTokenParser, loginRequired, async(req: RequestWithUser, res) => {
     const user = req.user;
     const { pagePath, fileNameOrId } = req.query;
 
@@ -96,9 +101,10 @@ export const routesFactory = (crowi): any => {
     }
 
     // convert ObjectId
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
     const orConditions: any[] = [{ originalName: fileNameOrId }];
-    if (ObjectId.isValid(fileNameOrId)) {
-      orConditions.push({ _id: ObjectId(fileNameOrId) });
+    if (fileNameOrId != null && ObjectId.isValid(fileNameOrId.toString())) {
+      orConditions.push({ _id: new ObjectId(fileNameOrId.toString()) });
     }
 
     const Attachment = model<IAttachment>('Attachment');
@@ -125,19 +131,16 @@ export const routesFactory = (crowi): any => {
       return;
     }
 
-    // serialize User data
-    attachment.creator = serializeUserSecurely(attachment.creator);
-
-    res.status(200).send({ attachment });
+    res.status(200).send({ attachment: serializeAttachmentSecurely(attachment) });
   });
 
   /**
    * return a list of Attachment
    */
-  router.get('/refs', accessTokenParser, loginRequired, async(req, res) => {
+  router.get('/refs', accessTokenParser, loginRequired, async(req: RequestWithUser, res) => {
     const user = req.user;
     const { prefix, pagePath } = req.query;
-    const options = JSON.parse(req.query.options);
+    const options: Record<string, string | undefined> = JSON.parse(req.query.options?.toString() ?? '');
 
     // check either 'prefix' or 'pagePath ' is specified
     if (prefix == null && pagePath == null) {
@@ -146,14 +149,20 @@ export const routesFactory = (crowi): any => {
     }
 
     // check regex
-    let regex;
-    const regexOptionValue = options.regexp || options.regex;
+    let regex: RegExp | null = null;
+    const regexOptionValue = options.regexp ?? options.regex;
     if (regexOptionValue != null) {
+      // check the length to avoid ReDoS
+      if (regexOptionValue.length > 400) {
+        res.status(400).send('the length of the \'regex\' option is too long.');
+        return;
+      }
+
       try {
         regex = generateRegexp(regexOptionValue);
       }
       catch (err) {
-        res.status(400).send(`the 'regex=${options.regex}' option is invalid as RegExp.`);
+        res.status(400).send('the \'regex\' option is invalid as RegExp.');
         return;
       }
     }
@@ -182,7 +191,8 @@ export const routesFactory = (crowi): any => {
       }
     }
     catch (err) {
-      return res.status(400).send(err);
+      const filterXSS = new FilterXSS();
+      return res.status(400).send(filterXSS.process(err.toString()));
     }
 
     const results = await pageQuery.select('id').exec();
@@ -207,14 +217,7 @@ export const routesFactory = (crowi): any => {
       .populate('creator')
       .exec();
 
-    // serialize User data
-    attachments.forEach((doc) => {
-      if (doc.creator != null && doc.creator instanceof User) {
-        doc.creator = serializeUserSecurely(doc.creator);
-      }
-    });
-
-    res.status(200).send({ attachments });
+    res.status(200).send({ attachments: attachments.map(attachment => serializeAttachmentSecurely(attachment)) });
   });
 
   return router;

+ 6 - 4
yarn.lock

@@ -2178,10 +2178,12 @@
     "@growi/ui" "link:packages/ui"
     axios "^0.24.0"
     bunyan "^1.8.15"
+    express "^4.19.2"
     hast-util-select "^5.0.5"
     mongoose "^6.11.3"
     swr "^2.0.3"
     universal-bunyan "^0.9.2"
+    xss "^1.0.15"
 
 "@growi/remark-drawio@link:packages/remark-drawio":
   version "1.0.0"
@@ -19341,10 +19343,10 @@ xpath@0.0.32:
   resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.32.tgz#1b73d3351af736e17ec078d6da4b8175405c48af"
   integrity sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==
 
-xss@^1.0.14:
-  version "1.0.14"
-  resolved "https://registry.yarnpkg.com/xss/-/xss-1.0.14.tgz#4f3efbde75ad0d82e9921cc3c95e6590dd336694"
-  integrity sha512-og7TEJhXvn1a7kzZGQ7ETjdQVS2UfZyTlsEdDOqvQF7GoxNfY+0YLCzBy1kPdsDDx4QuNAonQPddpsn6Xl/7sw==
+xss@^1.0.14, xss@^1.0.15:
+  version "1.0.15"
+  resolved "https://registry.yarnpkg.com/xss/-/xss-1.0.15.tgz#96a0e13886f0661063028b410ed1b18670f4e59a"
+  integrity sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==
   dependencies:
     commander "^2.20.3"
     cssfilter "0.0.10"