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

transplant some methods to PageListingService

Yuki Takei 6 месяцев назад
Родитель
Сommit
6d4bf314aa

+ 3 - 11
apps/app/src/interfaces/page-listing-results.ts

@@ -1,19 +1,11 @@
-import type { IPageHasId } from '@growi/core';
-
-import type { IPageForItem } from './page';
-
-type ParentPath = string;
+import type { IPageForTreeItem } from './page';
 
 export interface RootPageResult {
-  rootPage: IPageHasId;
-}
-
-export interface AncestorsChildrenResult {
-  ancestorsChildren: Record<ParentPath, Partial<IPageForItem>[]>;
+  rootPage: IPageForTreeItem;
 }
 
 export interface ChildrenResult {
-  children: Partial<IPageForItem>[];
+  children: IPageForTreeItem[];
 }
 
 export interface V5MigrationStatus {

+ 15 - 0
apps/app/src/interfaces/page.ts

@@ -21,6 +21,21 @@ export type IPageForItem = Partial<
   IPageHasId & { processData?: IPageOperationProcessData }
 >;
 
+export type IPageForTreeItem = Pick<
+  IPageHasId,
+  | '_id'
+  | 'path'
+  | 'parent'
+  | 'descendantCount'
+  | 'grant'
+  | 'isEmpty'
+  | 'createdAt'
+  | 'updatedAt'
+  | 'wip'
+> & {
+  processData?: IPageOperationProcessData;
+};
+
 export const UserGroupPageGrantStatus = {
   isGranted: 'isGranted',
   notGranted: 'notGranted',

+ 34 - 0
apps/app/src/server/models/openapi/page-listing.ts

@@ -0,0 +1,34 @@
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      PageForTreeItem:
+ *        description: Page
+ *        type: object
+ *        properties:
+ *          _id:
+ *            $ref: '#/components/schemas/ObjectId'
+ *          path:
+ *            $ref: '#/components/schemas/PagePath'
+ *          parent:
+ *            $ref: '#/components/schemas/PagePath'
+ *          grant:
+ *            $ref: '#/components/schemas/PageGrant'
+ *          lastUpdateUser:
+ *            $ref: '#/components/schemas/User'
+ *          descendantCount:
+ *            type: number
+ *          isEmpty:
+ *           type: boolean
+ *          wip:
+ *            type: boolean
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ *          updatedAt:
+ *            type: string
+ *            description: date updated at
+ *            example: 2010-01-01T00:00:00.000Z
+ */

+ 9 - 80
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -1,5 +1,5 @@
 import type {
-  IPageInfoForListing, IPageInfo, IPage, IUserHasId,
+  IPageInfoForListing, IPageInfo, IUserHasId,
 } from '@growi/core';
 import { getIdForRef, isIPageInfoForEntity } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
@@ -10,9 +10,11 @@ import { query, oneOf } from 'express-validator';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 
+import type { IPageForTreeItem } from '~/interfaces/page';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { configManager } from '~/server/service/config-manager';
 import type { IPageGrantService } from '~/server/service/page-grant';
+import { pageListingService } from '~/server/service/page-listing';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../../crowi';
@@ -87,88 +89,17 @@ const routerFactory = (crowi: Crowi): Router => {
    *               type: object
    *               properties:
    *                 rootPage:
-   *                   $ref: '#/components/schemas/Page'
+   *                   $ref: '#/components/schemas/PageForTreeItem'
    */
   router.get('/root',
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-      const Page = mongoose.model<IPage, PageModel>('Page');
-
-      let rootPage;
       try {
-        rootPage = await Page.findByPathAndViewer('/', req.user, null, true);
+        const rootPage: IPageForTreeItem = await pageListingService.findRootByViewer(req.user);
+        return res.apiv3({ rootPage });
       }
       catch (err) {
         return res.apiv3Err(new ErrorV3('rootPage not found'));
       }
-
-      return res.apiv3({ rootPage });
-    });
-
-  /**
-   * @swagger
-   *
-   * /page-listing/ancestors-children:
-   *   get:
-   *     tags: [PageListing]
-   *     security:
-   *       - bearer: []
-   *       - accessTokenInQuery: []
-   *     summary: /page-listing/ancestors-children
-   *     description: Get the ancestors and children of a page
-   *     parameters:
-   *       - name: path
-   *         in: query
-   *         required: true
-   *         schema:
-   *           type: string
-   *     responses:
-   *       200:
-   *         description: Get the ancestors and children of a page
-   *         content:
-   *           application/json:
-   *             schema:
-   *               type: object
-   *               properties:
-   *                 ancestorsChildren:
-   *                   type: object
-   *                   additionalProperties:
-   *                     type: object
-   *                     properties:
-   *                       _id:
-   *                         type: string
-   *                         description: Document ID
-   *                       descendantCount:
-   *                         type: integer
-   *                         description: Number of descendants
-   *                       isEmpty:
-   *                         type: boolean
-   *                         description: Indicates if the node is empty
-   *                       grant:
-   *                         type: integer
-   *                         description: Access level
-   *                       path:
-   *                         type: string
-   *                         description: Path string
-   *                       revision:
-   *                         type: string
-   *                         nullable: true
-   *                         description: Revision ID (nullable)
-   */
-  router.get('/ancestors-children',
-    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
-    loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
-      const { path } = req.query;
-
-      const pageService = crowi.pageService;
-      try {
-        const ancestorsChildren = await pageService.findAncestorsChildrenByPathAndViewer(path as string, req.user);
-        return res.apiv3({ ancestorsChildren });
-      }
-      catch (err) {
-        logger.error('Failed to get ancestorsChildren.', err);
-        return res.apiv3Err(new ErrorV3('Failed to get ancestorsChildren.'));
-      }
-
     });
 
   /**
@@ -202,7 +133,7 @@ const routerFactory = (crowi: Crowi): Router => {
    *                 children:
    *                   type: array
    *                   items:
-   *                     $ref: '#/components/schemas/Page'
+   *                     $ref: '#/components/schemas/PageForTreeItem'
    */
   /*
    * In most cases, using id should be prioritized
@@ -212,14 +143,12 @@ const routerFactory = (crowi: Crowi): Router => {
     loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
       const { id, path } = req.query;
 
-      const pageService = crowi.pageService;
-
       const hideRestrictedByOwner = await configManager.getConfig('security:list-policy:hideRestrictedByOwner');
       const hideRestrictedByGroup = await configManager.getConfig('security:list-policy:hideRestrictedByGroup');
 
       try {
-        const pages = await pageService.findChildrenByParentPathOrIdAndViewer(
-        (id || path) as string, req.user, undefined, !hideRestrictedByOwner, !hideRestrictedByGroup,
+        const pages = await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          (id || path) as string, req.user, !hideRestrictedByOwner, !hideRestrictedByGroup,
         );
         return res.apiv3({ children: pages });
       }

+ 1 - 0
apps/app/src/server/service/page-listing/index.ts

@@ -0,0 +1 @@
+export * from './page-listing';

+ 114 - 0
apps/app/src/server/service/page-listing/page-listing.ts

@@ -0,0 +1,114 @@
+import type { IUser } from '@growi/core/dist/interfaces';
+import { pagePathUtils } from '@growi/core/dist/utils';
+import mongoose, { type HydratedDocument } from 'mongoose';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+import { PageActionType, type IPageOperationProcessInfo, type IPageOperationProcessData } from '~/interfaces/page-operation';
+import { PageQueryBuilder, type PageDocument, type PageModel } from '~/server/models/page';
+import PageOperation from '~/server/models/page-operation';
+
+import type { IPageOperationService } from '../page-operation';
+
+const { hasSlash, generateChildrenRegExp } = pagePathUtils;
+
+
+export interface IPageListingService {
+  findRootByViewer(user: IUser): Promise<IPageForTreeItem>,
+  findChildrenByParentPathOrIdAndViewer(
+    parentPathOrId: string,
+    user?: IUser,
+    showPagesRestrictedByOwner?: boolean,
+    showPagesRestrictedByGroup?: boolean,
+  ): Promise<IPageForTreeItem[]>,
+}
+
+let pageOperationService: IPageOperationService;
+async function getPageOperationServiceInstance(): Promise<IPageOperationService> {
+  if (pageOperationService == null) {
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    pageOperationService = await import('../page-operation').then(mod => mod.pageOperationService!);
+  }
+  return pageOperationService;
+}
+
+class PageListingService implements IPageListingService {
+
+  async findRootByViewer(user?: IUser): Promise<IPageForTreeItem> {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+
+    const builder = new PageQueryBuilder(Page.findOne({ path: '/' }));
+    await builder.addViewerCondition(user);
+
+    return builder.query
+      .select('_id path parent descendantCount grant isEmpty createdAt updatedAt wip')
+      .lean()
+      .exec();
+  }
+
+  async findChildrenByParentPathOrIdAndViewer(
+      parentPathOrId: string,
+      user?: IUser,
+      showPagesRestrictedByOwner = false,
+      showPagesRestrictedByGroup = false,
+  ): Promise<IPageForTreeItem[]> {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    let queryBuilder: PageQueryBuilder;
+    if (hasSlash(parentPathOrId)) {
+      const path = parentPathOrId;
+      const regexp = generateChildrenRegExp(path);
+      queryBuilder = new PageQueryBuilder(Page.find({ path: { $regex: regexp } }), true);
+    }
+    else {
+      const parentId = parentPathOrId;
+      // Use $eq for user-controlled sources. see: https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/#recommendation
+      queryBuilder = new PageQueryBuilder(Page.find({ parent: { $eq: parentId } }), true);
+    }
+    await queryBuilder.addViewerCondition(user, null, undefined, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
+
+    const pages: HydratedDocument<Omit<IPageForTreeItem, 'processData'>>[] = await queryBuilder
+      .addConditionToSortPagesByAscPath()
+      .query
+      .select('_id path parent descendantCount grant isEmpty createdAt updatedAt wip')
+      .lean()
+      .exec();
+
+    const injectedPages = await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
+
+    // Type-safe conversion to IPageForTreeItem
+    return injectedPages.map(page => (
+      Object.assign(page, { _id: page._id.toString() })
+    ));
+  }
+
+  /**
+   * Inject processData into page docuements
+   * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
+   */
+  private async injectProcessDataIntoPagesByActionTypes<T>(
+      pages: HydratedDocument<T>[],
+      actionTypes: PageActionType[],
+  ): Promise<(HydratedDocument<T> & { processData?: IPageOperationProcessData })[]> {
+
+    const pageOperations = await PageOperation.find({ actionType: { $in: actionTypes } });
+    if (pageOperations == null || pageOperations.length === 0) {
+      return pages.map(page => Object.assign(page, { processData: undefined }));
+    }
+
+    const pageOperationService = await getPageOperationServiceInstance();
+    const processInfo: IPageOperationProcessInfo = pageOperationService.generateProcessInfo(pageOperations);
+    const operatingPageIds: string[] = Object.keys(processInfo);
+
+    // inject processData into pages
+    return pages.map((page) => {
+      const pageId = page._id.toString();
+      if (operatingPageIds.includes(pageId)) {
+        const processData: IPageOperationProcessData = processInfo[pageId];
+        return Object.assign(page, { processData });
+      }
+      return Object.assign(page, { processData: undefined });
+    });
+  }
+
+}
+
+export const pageListingService = new PageListingService();

+ 3 - 105
apps/app/src/server/service/page/index.ts

@@ -33,9 +33,7 @@ import {
   PageDeleteConfigValue, PageSingleDeleteCompConfigValue,
 } from '~/interfaces/page-delete-config';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
-import {
-  type IPageOperationProcessInfo, type IPageOperationProcessData, PageActionStage, PageActionType,
-} from '~/interfaces/page-operation';
+import { PageActionStage, PageActionType } from '~/interfaces/page-operation';
 import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import { SocketEventName, type PageMigrationErrorData, type UpdateDescCountRawData } from '~/interfaces/websocket';
 import type { CurrentPageYjsData } from '~/interfaces/yjs';
@@ -4312,45 +4310,10 @@ class PageService implements IPageService {
     return savedPage;
   }
 
-  /*
-   * Find all children by parent's path or id. Using id should be prioritized
-   */
-  async findChildrenByParentPathOrIdAndViewer(
-      parentPathOrId: string,
-      user,
-      userGroups = null,
-      showPagesRestrictedByOwner = false,
-      showPagesRestrictedByGroup = false,
-  ): Promise<(HydratedDocument<PageDocument> & { processData?: IPageOperationProcessData })[]> {
-    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
-    let queryBuilder: PageQueryBuilder;
-    if (hasSlash(parentPathOrId)) {
-      const path = parentPathOrId;
-      const regexp = generateChildrenRegExp(path);
-      queryBuilder = new PageQueryBuilder(Page.find({ path: { $regex: regexp } }), true);
-    }
-    else {
-      const parentId = parentPathOrId;
-      // Use $eq for user-controlled sources. see: https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/#recommendation
-      queryBuilder = new PageQueryBuilder(Page.find({ parent: { $eq: parentId } } as any), true); // TODO: improve type
-    }
-    await queryBuilder.addViewerCondition(user, userGroups, undefined, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
-
-    const pages: HydratedDocument<PageDocument>[] = await queryBuilder
-      .addConditionToSortPagesByAscPath()
-      .query
-      .lean()
-      .exec();
-
-    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
-
-    return pages;
-  }
-
   /**
    * Find all pages in trash page
    */
-  async findAllTrashPages(user: IUserHasId, userGroups = null): Promise<PageDocument[]> {
+  async findAllTrashPages(user: IUserHasId, userGroups = null): Promise<HydratedDocument<IPage>[]> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     // https://regex101.com/r/KYZWls/1
@@ -4360,80 +4323,15 @@ class PageService implements IPageService {
 
     await queryBuilder.addViewerCondition(user, userGroups);
 
-    const pages = await queryBuilder
+    const pages: HydratedDocument<IPage>[] = await queryBuilder
       .addConditionToSortPagesByAscPath()
       .query
       .lean()
       .exec();
 
-    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
-
     return pages;
   }
 
-  async findAncestorsChildrenByPathAndViewer(path: string, user, userGroups = null): Promise<Record<string, PageDocument[]>> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
-
-    const ancestorPaths = isTopPage(path) ? ['/'] : collectAncestorPaths(path); // root path is necessary for rendering
-    const regexps = ancestorPaths.map(path => generateChildrenRegExp(path)); // cannot use re2
-
-    // get pages at once
-    const queryBuilder = new PageQueryBuilder(Page.find({ path: { $in: regexps } }), true);
-    await queryBuilder.addViewerCondition(user, userGroups);
-    const pages = await queryBuilder
-      .addConditionAsOnTree()
-      .addConditionToMinimizeDataForRendering()
-      .addConditionToSortPagesByAscPath()
-      .query
-      .lean()
-      .exec();
-
-    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
-
-    /*
-     * If any non-migrated page is found during creating the pathToChildren map, it will stop incrementing at that moment
-     */
-    const pathToChildren: Record<string, PageDocument[]> = {};
-    const sortedPaths = ancestorPaths.sort((a, b) => a.length - b.length); // sort paths by path.length
-    sortedPaths.every((path) => {
-      const children = pages.filter(page => pathlib.dirname(page.path) === path);
-      if (children.length === 0) {
-        return false; // break when children do not exist
-      }
-      pathToChildren[path] = children;
-      return true;
-    });
-
-    return pathToChildren;
-  }
-
-  /**
-   * Inject processData into page docuements
-   * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
-   */
-  private async injectProcessDataIntoPagesByActionTypes(
-      pages: (HydratedDocument<PageDocument> & { processData?: IPageOperationProcessData })[],
-      actionTypes: PageActionType[],
-  ): Promise<void> {
-
-    const pageOperations = await PageOperation.find({ actionType: { $in: actionTypes } });
-    if (pageOperations == null || pageOperations.length === 0) {
-      return;
-    }
-
-    const processInfo: IPageOperationProcessInfo = this.crowi.pageOperationService.generateProcessInfo(pageOperations);
-    const operatingPageIds: string[] = Object.keys(processInfo);
-
-    // inject processData into pages
-    pages.forEach((page) => {
-      const pageId = page._id.toString();
-      if (operatingPageIds.includes(pageId)) {
-        const processData: IPageOperationProcessData = processInfo[pageId];
-        page.processData = processData;
-      }
-    });
-  }
-
   async getYjsData(pageId: string): Promise<CurrentPageYjsData> {
     const yjsService = getYjsService();
 

+ 0 - 4
apps/app/src/server/service/page/page-service.ts

@@ -31,10 +31,6 @@ export interface IPageService {
   findPageAndMetaDataByViewer(
       pageId: string | null, path: string, user?: HydratedDocument<IUser>, includeEmpty?: boolean, isSharedPage?: boolean,
   ): Promise<IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoAll>|null>
-  findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>,
-  findChildrenByParentPathOrIdAndViewer(
-    parentPathOrId: string, user, userGroups?, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
-  ): Promise<PageDocument[]>,
   resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument, activity?): Promise<void>
   handlePrivatePagesForGroupsToDelete(
     groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[],