Taichi Masuyama před 4 roky
rodič
revize
682e5df2a7
39 změnil soubory, kde provedl 1073 přidání a 174 odebrání
  1. 1 0
      packages/app/package.json
  2. 1 0
      packages/app/resource/locales/en_US/translation.json
  3. 1 0
      packages/app/resource/locales/ja_JP/translation.json
  4. 1 0
      packages/app/resource/locales/zh_CN/translation.json
  5. 1 0
      packages/app/src/client/services/PageContainer.js
  6. 23 0
      packages/app/src/components/Sidebar/PageTree.tsx
  7. 65 0
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  8. 18 0
      packages/app/src/components/Sidebar/PageTree/ItemNode.ts
  9. 96 0
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  10. 11 0
      packages/app/src/components/Sidebar/SidebarContents.jsx
  11. 1 0
      packages/app/src/components/Sidebar/SidebarNav.jsx
  12. 7 0
      packages/app/src/interfaces/common.ts
  13. 3 0
      packages/app/src/interfaces/has-object-id.ts
  14. 18 0
      packages/app/src/interfaces/page-listing-results.ts
  15. 24 4
      packages/app/src/interfaces/page.ts
  16. 3 2
      packages/app/src/migrations/20181019114028-abolish-page-group-relation.js
  17. 2 1
      packages/app/src/migrations/20190619055421-adjust-page-grant.js
  18. 2 1
      packages/app/src/migrations/20190624110950-fill-last-update-user.js
  19. 2 1
      packages/app/src/migrations/20190629193445-make-root-page-public.js
  20. 2 2
      packages/app/src/migrations/20191126173016-adjust-pages-path.js
  21. 2 1
      packages/app/src/migrations/20210420160380-convert-double-to-date.js
  22. 3 1
      packages/app/src/server/models/index.js
  23. 39 79
      packages/app/src/server/models/obsolete-page.js
  24. 337 0
      packages/app/src/server/models/page.ts
  25. 4 0
      packages/app/src/server/routes/apiv3/index.js
  26. 6 0
      packages/app/src/server/routes/apiv3/interfaces/apiv3-response.ts
  27. 81 0
      packages/app/src/server/routes/apiv3/page-listing.ts
  28. 3 3
      packages/app/src/server/routes/index.js
  29. 20 12
      packages/app/src/server/routes/installer.js
  30. 54 14
      packages/app/src/server/routes/page.js
  31. 6 24
      packages/app/src/server/service/page.js
  32. 22 0
      packages/app/src/server/views/layout-growi/select-go-to-page.html
  33. 1 0
      packages/app/src/server/views/widget/page_content.html
  34. 8 0
      packages/app/src/stores/context.tsx
  35. 32 0
      packages/app/src/stores/page-listing.tsx
  36. 26 0
      packages/app/src/stores/use-static-swr.tsx
  37. 26 23
      packages/app/src/test/integration/service/page.test.js
  38. 0 3
      packages/app/src/utils/swr-utils.ts
  39. 121 3
      yarn.lock

+ 1 - 0
packages/app/package.json

@@ -131,6 +131,7 @@
     "passport-saml": "^2.2.0",
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
+    "re2": "^1.16.0",
     "react-card-flip": "^1.0.10",
     "react-image-crop": "^8.3.0",
     "reconnecting-websocket": "^4.4.0",

+ 1 - 0
packages/app/resource/locales/en_US/translation.json

@@ -150,6 +150,7 @@
   "No bookmarks yet": "No bookmarks yet",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
+  "Page Tree": "Page Tree",
   "original_path":"Original path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",

+ 1 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -152,6 +152,7 @@
   "No bookmarks yet": "No bookmarks yet",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
+  "Page Tree": "ページツリー",
   "original_path":"元のパス",
   "new_path":"新しいパス",
   "duplicated_path":"重複したパス",

+ 1 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -158,6 +158,7 @@
   "No bookmarks yet": "暂无书签",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
+  "Page Tree": "页面树",
   "original_path":"Original path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",

+ 1 - 0
packages/app/src/client/services/PageContainer.js

@@ -82,6 +82,7 @@ export default class PageContainer extends Container {
       templateTagData: mainContent.getAttribute('data-template-tags') || null,
       shareLinksNumber: mainContent.getAttribute('data-share-links-number'),
       shareLinkId: JSON.parse(mainContent.getAttribute('data-share-link-id') || null),
+      targetAndAncestors: JSON.parse(mainContent.getAttribute('data-target-and-ancestors') || null),
 
       // latest(on remote) information
       remoteRevisionId: revisionId,

+ 23 - 0
packages/app/src/components/Sidebar/PageTree.tsx

@@ -0,0 +1,23 @@
+import React, { FC, memo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import ItemsTree from './PageTree/ItemsTree';
+
+
+const PageTree: FC = memo(() => {
+  const { t } = useTranslation();
+
+  return (
+    <>
+      <div className="grw-sidebar-content-header p-3 d-flex">
+        <h3 className="mb-0">{t('Page Tree')}</h3>
+      </div>
+
+      <div className="grw-sidebar-content-body p-3">
+        <ItemsTree />
+      </div>
+    </>
+  );
+});
+
+export default PageTree;

+ 65 - 0
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -0,0 +1,65 @@
+import React, { useCallback, useState, FC } from 'react';
+
+import { ItemNode } from './ItemNode';
+import { useSWRxPageChildren } from '../../../stores/page-listing';
+
+
+interface ItemProps {
+  itemNode: ItemNode
+  isOpen?: boolean
+}
+
+const Item: FC<ItemProps> = (props: ItemProps) => {
+  const { itemNode, isOpen: _isOpen = false } = props;
+
+  const { page, children } = itemNode;
+
+  const [currentChildren, setCurrentChildren] = useState(children);
+
+  const [isOpen, setIsOpen] = useState(_isOpen);
+
+  const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
+
+  const hasChildren = useCallback((): boolean => {
+    return currentChildren != null && currentChildren.length > 0;
+  }, [currentChildren]);
+
+  const onClickLoadChildren = useCallback(async() => {
+    setIsOpen(!isOpen);
+  }, [isOpen]);
+
+  /*
+   * When swr fetch succeeded
+   */
+  if (isOpen && error == null && data != null) {
+    const { children } = data;
+    itemNode.children = ItemNode.generateNodesFromPages(children);
+  }
+
+  // make sure itemNode.children and currentChildren are synced
+  if (children.length > currentChildren.length) {
+    setCurrentChildren(children);
+  }
+
+  // TODO: improve style
+  const style = { margin: '10px', opacity: 1.0 };
+  if (page.isTarget) style.opacity = 0.7;
+
+  return (
+    <div style={style}>
+      <p><button type="button" className="btn btn-light p-1" onClick={onClickLoadChildren}>Load</button>  {page.path}</p>
+      {
+        hasChildren() && currentChildren.map(node => (
+          <Item
+            key={node.page._id}
+            itemNode={node}
+            isOpen={false}
+          />
+        ))
+      }
+    </div>
+  );
+
+};
+
+export default Item;

+ 18 - 0
packages/app/src/components/Sidebar/PageTree/ItemNode.ts

@@ -0,0 +1,18 @@
+import { IPageForItem } from '../../../interfaces/page';
+
+export class ItemNode {
+
+  page: IPageForItem;
+
+  children: ItemNode[];
+
+  constructor(page: IPageForItem, children: ItemNode[] = []) {
+    this.page = page;
+    this.children = children;
+  }
+
+  static generateNodesFromPages(pages: IPageForItem[]): ItemNode[] {
+    return pages.map(page => new ItemNode(page));
+  }
+
+}

+ 96 - 0
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -0,0 +1,96 @@
+import React, { FC } from 'react';
+
+import { IPage } from '../../../interfaces/page';
+import { ItemNode } from './ItemNode';
+import Item from './Item';
+import { useSWRxPageAncestorsChildren } from '../../../stores/page-listing';
+import { useTargetAndAncestors } from '../../../stores/context';
+import { HasObjectId } from '../../../interfaces/has-object-id';
+
+
+/*
+ * Utility to generate initial node
+ */
+const generateInitialNodeBeforeResponse = (targetAndAncestors: Partial<IPage>[]): ItemNode => {
+  const nodes = targetAndAncestors.map((page): ItemNode => {
+    return new ItemNode(page, []);
+  });
+
+  // update children for each node
+  const rootNode = nodes.reduce((child, parent) => {
+    parent.children = [child];
+    return parent;
+  });
+
+  return rootNode;
+};
+
+const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Partial<IPage & HasObjectId>[]>, rootNode: ItemNode): ItemNode => {
+  const paths = Object.keys(ancestorsChildren);
+
+  let currentNode = rootNode;
+  paths.reverse().forEach((path) => {
+    const childPages = ancestorsChildren[path];
+    currentNode.children = ItemNode.generateNodesFromPages(childPages);
+
+    const nextNode = currentNode.children.filter((node) => {
+      return paths.includes(node.page.path as string);
+    })[0];
+    currentNode = nextNode;
+  });
+
+  return rootNode;
+};
+
+
+/*
+ * ItemsTree
+ */
+const ItemsTree: FC = () => {
+  // TODO: get from static SWR
+  const path = '/Sandbox';
+
+  const { data, error } = useTargetAndAncestors();
+
+  const { data: ancestorsChildrenData, error: error2 } = useSWRxPageAncestorsChildren(path);
+
+  if (error != null || error2 != null) {
+    return null;
+  }
+
+  if (data == null) {
+    return null;
+  }
+
+  const { targetAndAncestors, rootPage } = data;
+
+  let initialNode: ItemNode;
+
+  /*
+   * Before swr response comes back
+   */
+  if (ancestorsChildrenData == null) {
+    initialNode = generateInitialNodeBeforeResponse(targetAndAncestors);
+  }
+
+  /*
+   * When swr request finishes
+   */
+  else {
+    const { ancestorsChildren } = ancestorsChildrenData;
+
+    const rootNode = new ItemNode(rootPage);
+
+    initialNode = generateInitialNodeAfterResponse(ancestorsChildren, rootNode);
+  }
+
+  const isOpen = true;
+  return (
+    <>
+      <Item key={(initialNode as ItemNode).page.path} itemNode={(initialNode as ItemNode)} isOpen={isOpen} />
+    </>
+  );
+};
+
+
+export default ItemsTree;

+ 11 - 0
packages/app/src/components/Sidebar/SidebarContents.jsx

@@ -5,13 +5,21 @@ import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import NavigationContainer from '~/client/services/NavigationContainer';
+import { useTargetAndAncestors } from '../../stores/context';
 
 import RecentChanges from './RecentChanges';
 import CustomSidebar from './CustomSidebar';
+import PageTree from './PageTree';
 
 const SidebarContents = (props) => {
   const { navigationContainer, isSharedUser } = props;
 
+  const pageContainer = navigationContainer.getPageContainer();
+
+  const { targetAndAncestors } = pageContainer.state;
+
+  useTargetAndAncestors(targetAndAncestors);
+
   if (isSharedUser) {
     return null;
   }
@@ -21,6 +29,9 @@ const SidebarContents = (props) => {
     case 'recent':
       Contents = RecentChanges;
       break;
+    case 'tree':
+      Contents = PageTree;
+      break;
     default:
       Contents = CustomSidebar;
   }

+ 1 - 0
packages/app/src/components/Sidebar/SidebarNav.jsx

@@ -66,6 +66,7 @@ class SidebarNav extends React.Component {
         <div className="grw-sidebar-nav-primary-container">
           {!isSharedUser && <PrimaryItem id="custom" label="Custom Sidebar" iconName="code" />}
           {!isSharedUser && <PrimaryItem id="recent" label="Recent Changes" iconName="update" />}
+          {!isSharedUser && <PrimaryItem id="tree" label="Page Tree" iconName="format_list_bulleted" />}
           {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
           {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
         </div>

+ 7 - 0
packages/app/src/interfaces/common.ts

@@ -0,0 +1,7 @@
+/*
+ * Common types and interfaces
+ */
+
+
+// Foreign key field
+export type Ref<T> = string | T;

+ 3 - 0
packages/app/src/interfaces/has-object-id.ts

@@ -0,0 +1,3 @@
+export interface HasObjectId {
+  _id: string,
+}

+ 18 - 0
packages/app/src/interfaces/page-listing-results.ts

@@ -0,0 +1,18 @@
+import { IPageForItem } from './page';
+
+
+type ParentPath = string;
+export interface AncestorsChildrenResult {
+  ancestorsChildren: Record<ParentPath, Partial<IPageForItem>[]>
+}
+
+
+export interface ChildrenResult {
+  children: Partial<IPageForItem>[]
+}
+
+
+export interface TargetAndAncestors {
+  targetAndAncestors: Partial<IPageForItem>[]
+  rootPage: Partial<IPageForItem>,
+}

+ 24 - 4
packages/app/src/interfaces/page.ts

@@ -1,14 +1,34 @@
+import { Ref } from './common';
 import { IUser } from './user';
 import { IRevision } from './revision';
 import { ITag } from './tag';
+import { HasObjectId } from './has-object-id';
+
 
 export type IPage = {
   path: string,
   status: string,
-  revision: IRevision,
-  tags: ITag[],
-  creator: IUser,
+  revision: Ref<IRevision>,
+  tags: Ref<ITag>[],
+  creator: Ref<IUser>,
   createdAt: Date,
   updatedAt: Date,
-  seenUsers: string[]
+  seenUsers: Ref<IUser>[],
+  parent: Ref<IPage>,
+  isEmpty: boolean,
+  redirectTo: string,
+  grant: number,
+  grantedUsers: Ref<IUser>[],
+  grantedGroup: Ref<any>,
+  lastUpdateUser: Ref<IUser>,
+  liker: Ref<IUser>[],
+  commentCount: number
+  slackChannels: string,
+  pageIdOnHackmd: string,
+  revisionHackmdSynced: Ref<IRevision>,
+  hasDraftOnHackmd: boolean,
+  deleteUser: Ref<IUser>,
+  deletedAt: Date,
 }
+
+export type IPageForItem = Partial<IPage & {isTarget?: boolean} & HasObjectId>;

+ 3 - 2
packages/app/src/migrations/20181019114028-abolish-page-group-relation.js

@@ -2,6 +2,7 @@ import mongoose from 'mongoose';
 
 import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
+import getPageModel from '~/server/models/page';
 
 const logger = loggerFactory('growi:migrate:abolish-page-group-relation');
 
@@ -37,7 +38,7 @@ module.exports = {
       return;
     }
 
-    const Page = getModelSafely('Page') || require('~/server/models/page')();
+    const Page = getModelSafely('Page') || getPageModel();
     const UserGroup = getModelSafely('UserGroup') || require('~/server/models/user-group')();
 
     // retrieve all documents from 'pagegrouprelations'
@@ -74,7 +75,7 @@ module.exports = {
     logger.info('Rollback migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const Page = getModelSafely('Page') || require('~/server/models/page')();
+    const Page = getModelSafely('Page') || getPageModel();
     const UserGroup = getModelSafely('UserGroup') || require('~/server/models/user-group')();
 
     // retrieve all Page documents which granted by UserGroup

+ 2 - 1
packages/app/src/migrations/20190619055421-adjust-page-grant.js

@@ -2,6 +2,7 @@ import mongoose from 'mongoose';
 
 import { getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
+import getPageModel from '~/server/models/page';
 
 const logger = loggerFactory('growi:migrate:adjust-page-grant');
 
@@ -11,7 +12,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const Page = require('~/server/models/page')();
+    const Page = getPageModel();
 
     await Page.bulkWrite([
       {

+ 2 - 1
packages/app/src/migrations/20190624110950-fill-last-update-user.js

@@ -2,6 +2,7 @@ import mongoose from 'mongoose';
 
 import { getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
+import getPageModel from '~/server/models/page';
 
 const logger = loggerFactory('growi:migrate:abolish-page-group-relation');
 
@@ -14,7 +15,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const Page = require('~/server/models/page')();
+    const Page = getPageModel();
 
     // see https://stackoverflow.com/questions/3974985/update-mongodb-field-using-value-of-another-field/37280419#37280419
 

+ 2 - 1
packages/app/src/migrations/20190629193445-make-root-page-public.js

@@ -2,6 +2,7 @@ import mongoose from 'mongoose';
 
 import { getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
+import getPageModel from '~/server/models/page';
 
 const logger = loggerFactory('growi:migrate:make-root-page-public');
 
@@ -10,7 +11,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const Page = require('~/server/models/page')();
+    const Page = getPageModel();
 
     await Page.findOneAndUpdate(
       { path: '/' },

+ 2 - 2
packages/app/src/migrations/20191126173016-adjust-pages-path.js

@@ -1,6 +1,6 @@
 import mongoose from 'mongoose';
 import { pathUtils, getMongoUri, mongoOptions } from '@growi/core';
-
+import getPageModel from '~/server/models/page';
 
 import loggerFactory from '~/utils/logger';
 
@@ -12,7 +12,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const Page = require('~/server/models/page')();
+    const Page = getPageModel();
 
     // retrieve target data
     const pages = await Page.find({ path: /^(?!\/)/ });

+ 2 - 1
packages/app/src/migrations/20210420160380-convert-double-to-date.js

@@ -2,6 +2,7 @@ import mongoose from 'mongoose';
 
 import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
+import getPageModel from '~/server/models/page';
 
 const logger = loggerFactory('growi:migrate:remove-crowi-lauout');
 
@@ -10,7 +11,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const Page = getModelSafely('Page') || require('~/server/models/page')();
+    const Page = getModelSafely('Page') || getPageModel();
 
     const pages = await Page.find({ updatedAt: { $type: 'double' } });
 

+ 3 - 1
packages/app/src/server/models/index.js

@@ -1,5 +1,7 @@
+import Page from '~/server/models/page';
+
 module.exports = {
-  Page: require('./page'),
+  Page,
   // TODO GW-2746 bulk export pages
   // PageArchive: require('./page-archive'),
   PageTagRelation: require('./page-tag-relation'),

+ 39 - 79
packages/app/src/server/models/page.js → packages/app/src/server/models/obsolete-page.js

@@ -10,8 +10,6 @@ const debug = require('debug')('growi:models:page');
 const nodePath = require('path');
 const urljoin = require('url-join');
 const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
 const differenceInYears = require('date-fns/differenceInYears');
 
 const { pathUtils } = require('growi-commons');
@@ -22,11 +20,6 @@ const { checkTemplatePath } = templateChecker;
 
 const logger = loggerFactory('growi:models:page');
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-/*
- * define schema
- */
 const GRANT_PUBLIC = 1;
 const GRANT_RESTRICTED = 2;
 const GRANT_SPECIFIED = 3;
@@ -36,44 +29,11 @@ const PAGE_GRANT_ERROR = 1;
 const STATUS_PUBLISHED = 'published';
 const STATUS_DELETED = 'deleted';
 
-const pageSchema = new mongoose.Schema({
-  parent: {
-    type: ObjectId, ref: 'Page', index: true, default: null,
-  },
-  isEmpty: { type: Boolean, default: false },
-  path: {
-    type: String, required: true,
-  },
-  revision: { type: ObjectId, ref: 'Revision' },
-  redirectTo: { type: String, index: true },
-  status: { type: String, default: STATUS_PUBLISHED, index: true },
-  grant: { type: Number, default: GRANT_PUBLIC, index: true },
-  grantedUsers: [{ type: ObjectId, ref: 'User' }],
-  grantedGroup: { type: ObjectId, ref: 'UserGroup', index: true },
-  creator: { type: ObjectId, ref: 'User', index: true },
-  lastUpdateUser: { type: ObjectId, ref: 'User' },
-  liker: [{ type: ObjectId, ref: 'User' }],
-  seenUsers: [{ type: ObjectId, ref: 'User' }],
-  commentCount: { type: Number, default: 0 },
-  slackChannels: { type: String },
-  pageIdOnHackmd: String,
-  revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
-  hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
-  createdAt: { type: Date, default: Date.now },
-  updatedAt: { type: Date, default: Date.now },
-  deleteUser: { type: ObjectId, ref: 'User' },
-  deletedAt: { type: Date },
-}, {
-  toJSON: { getters: true },
-  toObject: { getters: true },
-});
-// apply plugins
-pageSchema.plugin(mongoosePaginate);
-pageSchema.plugin(uniqueValidator);
-
-// TODO: test this after modifying Page.create
-// ensure v4 compatibility using partial index
-pageSchema.index({ path: 1 }, { unique: true, partialFilterExpression: { parent: null } });
+// schema definition has moved to page.ts
+const pageSchema = {
+  statics: {},
+  methods: {},
+};
 
 /**
  * return an array of ancestors paths that is extracted from specified pagePath
@@ -117,7 +77,7 @@ const populateDataToShowRevision = (page, userPublicFields) => {
 /* eslint-enable object-curly-newline, object-property-newline */
 
 
-class PageQueryBuilder {
+export class PageQueryBuilder {
 
   constructor(query) {
     this.query = query;
@@ -259,6 +219,21 @@ class PageQueryBuilder {
     return this;
   }
 
+  /*
+   * Add this condition when get any ancestor pages including the target's parent
+   */
+  addConditionToSortAncestorPages() {
+    this.query = this.query.sort('-path');
+
+    return this;
+  }
+
+  addConditionToMinimizeDataForRendering() {
+    this.query = this.query.select('_id path isEmpty grant');
+
+    return this;
+  }
+
   addConditionToListByPathsArray(paths) {
     this.query = this.query
       .and({
@@ -286,7 +261,7 @@ class PageQueryBuilder {
 
 }
 
-module.exports = function(crowi) {
+export const getPageSchema = (crowi) => {
   let pageEvent;
 
   // init event
@@ -596,31 +571,6 @@ module.exports = function(crowi) {
     return this.findOne({ path });
   };
 
-  /**
-   * @param {string} path Page path
-   * @param {User} user User instance
-   * @param {UserGroup[]} userGroups List of UserGroup instances
-   */
-  pageSchema.statics.findByPathAndViewer = async function(path, user, userGroups) {
-    if (path == null) {
-      throw new Error('path is required.');
-    }
-
-    const baseQuery = this.findOne({ path });
-
-    let relatedUserGroups = userGroups;
-    if (user != null && relatedUserGroups == null) {
-      validateCrowi();
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
-
-    const queryBuilder = new PageQueryBuilder(baseQuery);
-    queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
-
-    return await queryBuilder.query.exec();
-  };
-
   /**
    * @param {string} path Page path
    * @param {User} user User instance
@@ -966,9 +916,9 @@ module.exports = function(crowi) {
 
     const Page = this;
     const Revision = crowi.model('Revision');
-    const format = options.format || 'markdown';
-    const redirectTo = options.redirectTo || null;
-    const grantUserGroupId = options.grantUserGroupId || null;
+    const {
+      format = 'markdown', redirectTo, grantUserGroupId, parentId,
+    } = options;
 
     // sanitize path
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
@@ -979,10 +929,19 @@ module.exports = function(crowi) {
       grant = GRANT_PUBLIC;
     }
 
-    const isExist = await this.count({ path });
+    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    // for v4 compatibility
+    if (!isV5Compatible) {
+      const isExist = await this.count({ path });
 
-    if (isExist) {
-      throw new Error('Cannot create new page to existed path');
+      if (isExist) {
+        throw new Error('Cannot create new page to existed path');
+      }
+    }
+
+    let parent = parentId;
+    if (isV5Compatible && parent == null && !isTopPage(path)) {
+      parent = await Page.getParentIdAndFillAncestors(path);
     }
 
     const page = new Page();
@@ -991,6 +950,7 @@ module.exports = function(crowi) {
     page.lastUpdateUser = user;
     page.redirectTo = redirectTo;
     page.status = STATUS_PUBLISHED;
+    page.parent = parent;
 
     await validateAppliedScope(user, grant, grantUserGroupId);
     page.applyScope(user, grant, grantUserGroupId);
@@ -1172,5 +1132,5 @@ module.exports = function(crowi) {
 
   pageSchema.statics.PageQueryBuilder = PageQueryBuilder;
 
-  return mongoose.model('Page', pageSchema);
+  return pageSchema;
 };

+ 337 - 0
packages/app/src/server/models/page.ts

@@ -0,0 +1,337 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import mongoose, {
+  Schema, Model, Document,
+} from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+import uniqueValidator from 'mongoose-unique-validator';
+import nodePath from 'path';
+import RE2 from 're2';
+
+import { getOrCreateModel, pagePathUtils } from '@growi/core';
+import loggerFactory from '../../utils/logger';
+import Crowi from '../crowi';
+import { IPage } from '../../interfaces/page';
+import { getPageSchema, PageQueryBuilder } from './obsolete-page';
+
+const { isTopPage } = pagePathUtils;
+
+const logger = loggerFactory('growi:models:page');
+
+
+/*
+ * define schema
+ */
+const GRANT_PUBLIC = 1;
+const GRANT_RESTRICTED = 2;
+const GRANT_SPECIFIED = 3;
+const GRANT_OWNER = 4;
+const GRANT_USER_GROUP = 5;
+const PAGE_GRANT_ERROR = 1;
+const STATUS_PUBLISHED = 'published';
+const STATUS_DELETED = 'deleted';
+
+export interface PageDocument extends IPage, Document {}
+
+type TargetAndAncestorsResult = {
+  targetAndAncestors: PageDocument[]
+  rootPage: PageDocument
+}
+export interface PageModel extends Model<PageDocument> {
+  createEmptyPagesByPaths(paths: string[]): Promise<void>
+  getParentIdAndFillAncestors(path: string): Promise<string | null>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?): Promise<PageDocument[]>
+  findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
+  findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
+  findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
+}
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+const schema = new Schema<PageDocument, PageModel>({
+  parent: {
+    type: ObjectId, ref: 'Page', index: true, default: null,
+  },
+  isEmpty: { type: Boolean, default: false },
+  path: {
+    type: String, required: true, index: true,
+  },
+  revision: { type: ObjectId, ref: 'Revision' },
+  redirectTo: { type: String, index: true },
+  status: { type: String, default: STATUS_PUBLISHED, index: true },
+  grant: { type: Number, default: GRANT_PUBLIC, index: true },
+  grantedUsers: [{ type: ObjectId, ref: 'User' }],
+  grantedGroup: { type: ObjectId, ref: 'UserGroup', index: true },
+  creator: { type: ObjectId, ref: 'User', index: true },
+  lastUpdateUser: { type: ObjectId, ref: 'User' },
+  liker: [{ type: ObjectId, ref: 'User' }],
+  seenUsers: [{ type: ObjectId, ref: 'User' }],
+  commentCount: { type: Number, default: 0 },
+  slackChannels: { type: String },
+  pageIdOnHackmd: { type: String },
+  revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
+  hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
+  createdAt: { type: Date, default: Date.now },
+  updatedAt: { type: Date, default: Date.now },
+  deleteUser: { type: ObjectId, ref: 'User' },
+  deletedAt: { type: Date },
+}, {
+  toJSON: { getters: true },
+  toObject: { getters: true },
+});
+// apply plugins
+schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
+
+/*
+ * Methods
+ */
+const collectAncestorPaths = (path: string, ancestorPaths: string[] = []): string[] => {
+  if (isTopPage(path)) return ancestorPaths;
+
+  const parentPath = nodePath.dirname(path);
+  ancestorPaths.push(parentPath);
+  return collectAncestorPaths(parentPath, ancestorPaths);
+};
+
+
+const hasSlash = (str: string): boolean => {
+  return str.includes('/');
+};
+
+/*
+ * Generate RE2 instance for one level lower path
+ */
+const generateChildrenRE2 = (path: string): RE2 => {
+  // https://regex101.com/r/laJGzj/1
+  // ex. /any_level1
+  if (isTopPage(path)) return new RE2(/^\/[^/]+$/);
+
+  // https://regex101.com/r/mrDJrx/1
+  // ex. /parent/any_child OR /any_level1
+  return new RE2(`^${path}(\\/[^/]+)\\/?$`);
+};
+
+/*
+ * Generate RegExp instance for one level lower path
+ */
+const generateChildrenRegExp = (path: string): RegExp => {
+  // https://regex101.com/r/laJGzj/1
+  // ex. /any_level1
+  if (isTopPage(path)) return new RegExp(/^\/[^/]+$/);
+
+  // https://regex101.com/r/mrDJrx/1
+  // ex. /parent/any_child OR /any_level1
+  return new RegExp(`^${path}(\\/[^/]+)\\/?$`);
+};
+
+/*
+ * Create empty pages if the page in paths didn't exist
+ */
+schema.statics.createEmptyPagesByPaths = async function(paths: string[]): Promise<void> {
+  // find existing parents
+  const builder = new PageQueryBuilder(this.find({}, { _id: 0, path: 1 }));
+  const existingPages = await builder
+    .addConditionToListByPathsArray(paths)
+    .query
+    .lean()
+    .exec();
+  const existingPagePaths = existingPages.map(page => page.path);
+
+  // paths to create empty pages
+  const notExistingPagePaths = paths.filter(path => !existingPagePaths.includes(path));
+
+  // insertMany empty pages
+  try {
+    await this.insertMany(notExistingPagePaths.map(path => ({ path, isEmpty: true })));
+  }
+  catch (err) {
+    logger.error('Failed to insert empty pages.', err);
+    throw err;
+  }
+};
+
+/*
+ * Find the pages parent and update if the parent exists.
+ * If not,
+ *   - first   run createEmptyPagesByPaths with ancestor's paths to ensure all the ancestors exist
+ *   - second  update ancestor pages' parent
+ *   - finally return the target's parent page id
+ */
+schema.statics.getParentIdAndFillAncestors = async function(path: string): Promise<Schema.Types.ObjectId> {
+  const parentPath = nodePath.dirname(path);
+
+  const parent = await this.findOne({ path: parentPath }); // find the oldest parent which must always be the true parent
+  if (parent != null) { // fill parents if parent is null
+    return parent._id;
+  }
+
+  const ancestorPaths = collectAncestorPaths(path); // paths of parents need to be created
+
+  // just create ancestors with empty pages
+  await this.createEmptyPagesByPaths(ancestorPaths);
+
+  // find ancestors
+  const builder = new PageQueryBuilder(this.find({}, { _id: 1, path: 1 }));
+  const ancestors = await builder
+    .addConditionToListByPathsArray(ancestorPaths)
+    .addConditionToSortAncestorPages()
+    .query
+    .lean()
+    .exec();
+
+
+  const ancestorsMap = new Map(); // Map<path, _id>
+  ancestors.forEach(page => ancestorsMap.set(page.path, page._id));
+
+  // bulkWrite to update ancestors
+  const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
+  const operations = nonRootAncestors.map((page) => {
+    const { path } = page;
+    const parentPath = nodePath.dirname(path);
+    return {
+      updateOne: {
+        filter: {
+          path,
+        },
+        update: {
+          parent: ancestorsMap.get(parentPath),
+        },
+      },
+    };
+  });
+  await this.bulkWrite(operations);
+
+  const parentId = ancestorsMap.get(parentPath);
+  return parentId;
+};
+
+// Utility function to add viewer condition to PageQueryBuilder instance
+const addViewerCondition = async(queryBuilder: PageQueryBuilder, user, userGroups = null): Promise<void> => {
+  let relatedUserGroups = userGroups;
+  if (user != null && relatedUserGroups == null) {
+    const UserGroupRelation: any = mongoose.model('UserGroupRelation');
+    relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+  }
+
+  queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
+};
+
+/*
+ * Find a page by path and viewer. Pass false to useFindOne to use findOne method.
+ */
+schema.statics.findByPathAndViewer = async function(
+    path: string | null, user, userGroups = null, useFindOne = true,
+): Promise<PageDocument | PageDocument[] | null> {
+  if (path == null) {
+    throw new Error('path is required.');
+  }
+
+  const baseQuery = useFindOne ? this.findOne({ path }) : this.find({ path });
+  const queryBuilder = new PageQueryBuilder(baseQuery);
+  await addViewerCondition(queryBuilder, user, userGroups);
+
+  return queryBuilder.query.exec();
+};
+
+
+/*
+ * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result
+ * The result will include the target as well
+ */
+schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: string): Promise<TargetAndAncestorsResult> {
+  let path;
+  if (!hasSlash(pathOrId)) {
+    const _id = pathOrId;
+    const page = await this.findOne({ _id });
+    if (page == null) throw new Error('Page not found.');
+
+    path = page.path;
+  }
+  else {
+    path = pathOrId;
+  }
+
+  const ancestorPaths = collectAncestorPaths(path);
+  ancestorPaths.push(path); // include target
+
+  // Do not populate
+  const queryBuilder = new PageQueryBuilder(this.find());
+  const _targetAndAncestors: PageDocument[] = await queryBuilder
+    .addConditionToListByPathsArray(ancestorPaths)
+    .addConditionToMinimizeDataForRendering()
+    .addConditionToSortAncestorPages()
+    .query
+    .lean()
+    .exec();
+
+  // no same path pages
+  const ancestorsMap = new Map<string, PageDocument>();
+  _targetAndAncestors.forEach(page => ancestorsMap.set(page.path, page));
+  const targetAndAncestors = Array.from(ancestorsMap.values());
+  const rootPage = targetAndAncestors[targetAndAncestors.length - 1];
+
+  return { targetAndAncestors, rootPage };
+};
+
+/*
+ * Find all children by parent's path or id. Using id should be prioritized
+ */
+schema.statics.findChildrenByParentPathOrIdAndViewer = async function(parentPathOrId: string, user, userGroups = null): Promise<PageDocument[]> {
+  let queryBuilder: PageQueryBuilder;
+  if (hasSlash(parentPathOrId)) {
+    const path = parentPathOrId;
+    const regexp = generateChildrenRE2(path);
+    queryBuilder = new PageQueryBuilder(this.find({ path: { $regex: regexp.source } }));
+  }
+  else {
+    const parentId = parentPathOrId;
+    queryBuilder = new PageQueryBuilder(this.find({ parent: parentId }));
+  }
+  await addViewerCondition(queryBuilder, user, userGroups);
+
+  return queryBuilder.query.lean().exec();
+};
+
+schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: string, user, userGroups = null): Promise<Record<string, PageDocument[]>> {
+  const ancestorPaths = isTopPage(path) ? ['/'] : collectAncestorPaths(path);
+  const regexps = ancestorPaths.map(path => new RegExp(generateChildrenRegExp(path))); // cannot use re2
+
+  // get pages at once
+  const queryBuilder = new PageQueryBuilder(this.find({ path: { $in: regexps } }));
+  await addViewerCondition(queryBuilder, user, userGroups);
+  const _pages = await queryBuilder
+    .addConditionToMinimizeDataForRendering()
+    .query
+    .lean()
+    .exec();
+  // mark target
+  const pages = _pages.map((page: PageDocument & {isTarget?: boolean}) => {
+    if (page.path === path) {
+      page.isTarget = true;
+    }
+    return page;
+  });
+
+  // make map
+  const pathToChildren: Record<string, PageDocument[]> = {};
+  ancestorPaths.forEach((path) => {
+    pathToChildren[path] = pages.filter(page => nodePath.dirname(page.path) === path);
+  });
+
+  return pathToChildren;
+};
+
+
+/*
+ * Merge obsolete page model methods and define new methods which depend on crowi instance
+ */
+export default (crowi: Crowi): any => {
+  // add old page schema methods
+  const pageSchema = getPageSchema(crowi);
+  schema.methods = { ...pageSchema.methods, ...schema.methods };
+  schema.statics = { ...pageSchema.statics, ...schema.statics };
+
+  return getOrCreateModel<PageDocument, PageModel>('Page', schema);
+};

+ 4 - 0
packages/app/src/server/routes/apiv3/index.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import pageListing from './page-listing';
+
 const logger = loggerFactory('growi:routes:apiv3'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -41,6 +43,8 @@ module.exports = (crowi) => {
   router.use('/pages', require('./pages')(crowi));
   router.use('/revisions', require('./revisions')(crowi));
 
+  router.use('/page-listing', pageListing(crowi));
+
   router.use('/share-links', require('./share-links')(crowi));
 
   router.use('/bookmarks', require('./bookmarks')(crowi));

+ 6 - 0
packages/app/src/server/routes/apiv3/interfaces/apiv3-response.ts

@@ -0,0 +1,6 @@
+import { Response } from 'express';
+
+export interface ApiV3Response extends Response {
+  apiv3(obj?: any, status?: number): any
+  apiv3Err(_err: any, status?: number, info?: any): any
+}

+ 81 - 0
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -0,0 +1,81 @@
+import express, { Request, Router } from 'express';
+import { query, oneOf } from 'express-validator';
+
+import { PageDocument, PageModel } from '../../models/page';
+import ErrorV3 from '../../models/vo/error-apiv3';
+import loggerFactory from '../../../utils/logger';
+import Crowi from '../../crowi';
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:page-tree');
+
+/*
+ * Types & Interfaces
+ */
+interface AuthorizedRequest extends Request {
+  user?: any
+}
+
+/*
+ * Validators
+ */
+const validator = {
+  pagePathRequired: [
+    query('path').isString().withMessage('path is required'),
+  ],
+  pageIdOrPathRequired: oneOf([
+    query('id').isMongoId(),
+    query('path').isString(),
+  ], 'id or path is required'),
+};
+
+/*
+ * Routes
+ */
+export default (crowi: Crowi): Router => {
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
+  // Do not use loginRequired with isGuestAllowed true since page tree may show private page titles
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+
+  const router = express.Router();
+
+
+  // eslint-disable-next-line max-len
+  router.get('/ancestors-children', accessTokenParser, loginRequiredStrictly, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
+    const { path } = req.query;
+
+    const Page: PageModel = crowi.model('Page');
+
+    try {
+      const ancestorsChildren = await Page.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.'));
+    }
+
+  });
+
+  /*
+   * In most cases, using id should be prioritized
+   */
+  // eslint-disable-next-line max-len
+  router.get('/children', accessTokenParser, loginRequiredStrictly, validator.pageIdOrPathRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const { id, path } = req.query;
+
+    const Page: PageModel = crowi.model('Page');
+
+    try {
+      const pages = await Page.findChildrenByParentPathOrIdAndViewer((id || path)as string, req.user);
+      return res.apiv3({ children: pages });
+    }
+    catch (err) {
+      logger.error('Error occurred while finding children.', err);
+      return res.apiv3Err(new ErrorV3('Error occurred while finding children.'));
+    }
+  });
+
+  return router;
+};

+ 3 - 3
packages/app/src/server/routes/index.js

@@ -139,7 +139,7 @@ module.exports = function(crowi, app) {
   // my drafts
   app.get('/me/drafts'                , loginRequiredStrictly, me.drafts.list);
 
-  app.get('/:id([0-9a-z]{24})'       , loginRequired , page.redirector);
+  app.get('/:id([0-9a-z]{24})'       , loginRequired , page.showPage);
   app.get('/_r/:id([0-9a-z]{24})'    , loginRequired , page.redirector); // alias
   app.get('/attachment/:id([0-9a-z]{24})' , certifySharedFile , loginRequired, attachment.api.get);
   app.get('/attachment/profile/:id([0-9a-z]{24})' , loginRequired, attachment.api.get);
@@ -196,7 +196,7 @@ module.exports = function(crowi, app) {
 
   app.get('/share/:linkId', page.showSharedPage);
 
-  app.get('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);
-  app.get('/*'                     , loginRequired , autoReconnectToSearch, page.showPage, page.notFound);
+  app.get('/*/$'                   , loginRequired , page.redirectorWithEndOfSlash, page.notFound);
+  app.get('/*'                     , loginRequired , autoReconnectToSearch, page.redirector, page.notFound);
 
 };

+ 20 - 12
packages/app/src/server/routes/installer.js

@@ -33,18 +33,15 @@ module.exports = function(crowi) {
   }
 
   async function createInitialPages(owner, lang) {
-    const promises = [];
-
-    // create portal page for '/'
-    promises.push(createPage(path.join(crowi.localeDir, lang, 'welcome.md'), '/', owner, lang));
-
-    // create /Sandbox/*
-    promises.push(createPage(path.join(crowi.localeDir, lang, 'sandbox.md'), '/Sandbox', owner, lang));
-    promises.push(createPage(path.join(crowi.localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner, lang));
-    promises.push(createPage(path.join(crowi.localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner, lang));
-    promises.push(createPage(path.join(crowi.localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner, lang));
-
-    await Promise.all(promises);
+    /*
+     * Keep in this order to avoid creating the same pages
+     */
+    await createPage(path.join(crowi.localeDir, lang, 'sandbox.md'), '/Sandbox', owner, lang);
+    await Promise.all([
+      createPage(path.join(crowi.localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner, lang),
+      createPage(path.join(crowi.localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner, lang),
+      createPage(path.join(crowi.localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner, lang),
+    ]);
 
     try {
       await initSearchIndex();
@@ -73,6 +70,9 @@ module.exports = function(crowi) {
 
     await appService.initDB(language);
 
+    // create the root page before creating admin user
+    await createPage(path.join(crowi.localeDir, language, 'welcome.md'), '/', { _id: '000000000000000000000000' }, language); // use 0 as a mock user id
+
     // create first admin user
     // TODO: with transaction
     let adminUser;
@@ -84,6 +84,14 @@ module.exports = function(crowi) {
       req.form.errors.push(req.t('message.failed_to_create_admin_user', { errMessage: err.message }));
       return res.render('installer');
     }
+    // add owner after creating admin user
+    const Revision = crowi.model('Revision');
+    const rootPage = await Page.findOne({ path: '/' });
+    const rootRevision = await Revision.findOne({ path: '/' });
+    rootPage.creator = adminUser;
+    rootRevision.creator = adminUser;
+    await Promise.all([rootPage.save(), rootRevision.save()]);
+
     // create initial pages
     await createInitialPages(adminUser, language);
 

+ 54 - 14
packages/app/src/server/routes/page.js

@@ -1,9 +1,10 @@
 import { pagePathUtils } from '@growi/core';
+import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 
 import UpdatePost from '../models/update-post';
 
-const { isCreatablePage } = pagePathUtils;
+const { isCreatablePage, isTopPage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
 const { serializeUserSecurely } = require('../models/serializers/user-serializer');
@@ -263,6 +264,16 @@ module.exports = function(crowi, app) {
     renderVars.pages = result.pages;
   }
 
+  async function addRenderVarsForPageTree(renderVars, path) {
+    const { targetAndAncestors, rootPage } = await Page.findTargetAndAncestorsByPathOrId(path);
+
+    if (targetAndAncestors.length === 0 && !isTopPage(path)) {
+      throw new Error('Ancestors must have at least one page.');
+    }
+
+    renderVars.targetAndAncestors = { targetAndAncestors, rootPage };
+  }
+
   function replacePlaceholdersOfTemplate(template, req) {
     if (req.user == null) {
       return '';
@@ -279,10 +290,10 @@ module.exports = function(crowi, app) {
   }
 
   async function showPageForPresentation(req, res, next) {
-    const path = getPathFromRequest(req);
+    const id = req.params.id;
     const { revisionId } = req.query;
 
-    let page = await Page.findByPathAndViewer(path, req.user);
+    let page = await Page.findByIdAndViewer(id, req.user);
 
     if (page == null) {
       next();
@@ -328,27 +339,32 @@ module.exports = function(crowi, app) {
 
     await addRenderVarsForDescendants(renderVars, portalPath, req.user, offset, limit);
 
+    await addRenderVarsForPageTree(renderVars, portalPath);
+
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
   }
 
   async function showPageForGrowiBehavior(req, res, next) {
-    const path = getPathFromRequest(req);
+    const id = req.params.id;
     const revisionId = req.query.revision;
 
-    let page = await Page.findByPathAndViewer(path, req.user);
+    let page = await Page.findByIdAndViewer(id, req.user);
 
     if (page == null) {
       // check the page is forbidden or just does not exist.
-      req.isForbidden = await Page.count({ path }) > 0;
+      req.isForbidden = await Page.count({ _id: id }) > 0;
       return next();
     }
+
+    const { path } = page; // this must exist
+
     if (page.redirectTo) {
       debug(`Redirect to '${page.redirectTo}'`);
       return res.redirect(`${encodeURI(page.redirectTo)}?redirectFrom=${encodeURIComponent(path)}`);
     }
 
-    logger.debug('Page is found when processing pageShowForGrowiBehavior', page._id, page.path);
+    logger.debug('Page is found when processing pageShowForGrowiBehavior', page._id, path);
 
     const limit = 50;
     const offset = parseInt(req.query.offset) || 0;
@@ -373,12 +389,14 @@ module.exports = function(crowi, app) {
     const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: page._id });
     renderVars.sharelinksNumber = sharelinksNumber;
 
-    if (isUserPage(page.path)) {
+    if (isUserPage(path)) {
       // change template
       view = 'layout-growi/user_page';
       await addRenderVarsForUserPage(renderVars, page);
     }
 
+    await addRenderVarsForPageTree(renderVars, path);
+
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
   }
@@ -558,16 +576,38 @@ module.exports = function(crowi, app) {
   /**
    * redirector
    */
-  actions.redirector = async function(req, res) {
-    const id = req.params.id;
+  async function redirector(req, res, next, path) {
+    const pages = await Page.findByPathAndViewer(path, req.user, null, false);
+    const { redirectFrom } = req.query;
 
-    const page = await Page.findByIdAndViewer(id, req.user);
+    if (pages.length >= 2) {
+      // pass only redirectFrom since it is not sure whether the query params are related to the pages
+      return res.render('layout-growi/select-go-to-page', { pages, redirectFrom });
+    }
 
-    if (page != null) {
-      return res.redirect(encodeURI(page.path));
+    if (pages.length === 1) {
+      const url = new URL('https://dummy.origin');
+      url.pathname = `/${pages[0]._id}`;
+      Object.entries(req.query).forEach(([key, value], i) => {
+        url.searchParams.append(key, value);
+      });
+      return res.safeRedirect(urljoin(url.pathname, url.search));
     }
 
-    return res.redirect('/');
+    return next(); // to page.notFound
+  }
+
+  actions.redirector = async function(req, res, next) {
+    const path = getPathFromRequest(req);
+
+    return redirector(req, res, next, path);
+  };
+
+  actions.redirectorWithEndOfSlash = async function(req, res, next) {
+    const _path = getPathFromRequest(req);
+    const path = pathUtils.removeTrailingSlash(_path);
+
+    return redirector(req, res, next, path);
   };
 
 

+ 6 - 24
packages/app/src/server/service/page.js

@@ -6,8 +6,8 @@ const escapeStringRegexp = require('escape-string-regexp');
 const streamToPromise = require('stream-to-promise');
 const pathlib = require('path');
 
-const logger = loggerFactory('growi:models:page');
-const debug = require('debug')('growi:models:page');
+const logger = loggerFactory('growi:services:page');
+const debug = require('debug')('growi:services:page');
 const { Writable } = require('stream');
 const { createBatchStream } = require('~/server/util/batch-stream');
 
@@ -829,30 +829,12 @@ class PageService {
         const parentPathsSet = new Set(pages.map(page => pathlib.dirname(page.path)));
         const parentPaths = Array.from(parentPathsSet);
 
-        // find existing parents
-        const builder1 = new PageQueryBuilder(Page.find({}, { _id: 0, path: 1 }));
-        const existingParents = await builder1
-          .addConditionToListByPathsArray(parentPaths)
-          .query
-          .lean()
-          .exec();
-        const existingParentPaths = existingParents.map(parent => parent.path);
-
-        // paths to create empty pages
-        const notExistingParentPaths = parentPaths.filter(path => !existingParentPaths.includes(path));
-
-        // insertMany empty pages
-        try {
-          await Page.insertMany(notExistingParentPaths.map(path => ({ path, isEmpty: true })));
-        }
-        catch (err) {
-          logger.error('Failed to insert empty pages.', err);
-          throw err;
-        }
+        // fill parents with empty pages
+        await Page.createEmptyPagesByPaths(parentPaths);
 
         // find parents again
-        const builder2 = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }));
-        const parents = await builder2
+        const builder = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }));
+        const parents = await builder
           .addConditionToListByPathsArray(parentPaths)
           .query
           .lean()

+ 22 - 0
packages/app/src/server/views/layout-growi/select-go-to-page.html

@@ -0,0 +1,22 @@
+{% extends 'base/layout.html' %}
+
+<!-- WIP -->
+
+{% block content_main %}
+  <div>ContentMain</div>
+  <div>
+    {% for page in pages %}
+      <li>{{page._id.toString()}}: {{page.path}}</li>
+    {% endfor %}
+  </div>
+  <br>
+  <div>redirectFrom: {{redirectFrom}}</div>
+{% endblock %}
+
+{% block content_footer %}
+  <div>Footer</div>
+{% endblock %}
+
+{% block body_end %}
+  <div>BodyEnd</div>
+{% endblock %}

+ 1 - 0
packages/app/src/server/views/widget/page_content.html

@@ -27,6 +27,7 @@
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   data-share-links-number="{% if page %}{{ sharelinksNumber }}{% endif %}"
   data-share-link-id="{% if sharelink %}{{ sharelink._id|json }}{% endif %}"
+  data-target-and-ancestors="{% if targetAndAncestors %}{{ targetAndAncestors|json }}{% endif %}"
   >
 {% else %}
 <div id="content-main" class="content-main d-flex"

+ 8 - 0
packages/app/src/stores/context.tsx

@@ -0,0 +1,8 @@
+import { SWRResponse } from 'swr';
+import { useStaticSWR } from './use-static-swr';
+
+import { TargetAndAncestors } from '../interfaces/page-listing-results';
+
+export const useTargetAndAncestors = (initialData?: TargetAndAncestors): SWRResponse<TargetAndAncestors, Error> => {
+  return useStaticSWR<TargetAndAncestors, Error>('targetAndAncestors', initialData || null);
+};

+ 32 - 0
packages/app/src/stores/page-listing.tsx

@@ -0,0 +1,32 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiv3Get } from '../client/util/apiv3-client';
+import { AncestorsChildrenResult, ChildrenResult } from '../interfaces/page-listing-results';
+
+
+export const useSWRxPageAncestorsChildren = (
+    path: string,
+): SWRResponse<AncestorsChildrenResult, Error> => {
+  return useSWR(
+    `/page-listing/ancestors-children?path=${path}`,
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return {
+        ancestorsChildren: response.data.ancestorsChildren,
+      };
+    }),
+    { revalidateOnFocus: false },
+  );
+};
+
+export const useSWRxPageChildren = (
+    id?: string | null,
+): SWRResponse<ChildrenResult, Error> => {
+  return useSWR(
+    id ? `/page-listing/children?id=${id}` : null,
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return {
+        children: response.data.children,
+      };
+    }),
+  );
+};

+ 26 - 0
packages/app/src/stores/use-static-swr.tsx

@@ -0,0 +1,26 @@
+import {
+  Key, SWRConfiguration, SWRResponse, mutate,
+} from 'swr';
+import useSWRImmutable from 'swr/immutable';
+import { Fetcher } from 'swr/dist/types';
+
+
+export function useStaticSWR<Data, Error>(key: Key): SWRResponse<Data, Error>;
+export function useStaticSWR<Data, Error>(key: Key, data: Data | Fetcher<Data> | null): SWRResponse<Data, Error>;
+export function useStaticSWR<Data, Error>(key: Key, data: Data | Fetcher<Data> | null,
+  configuration: SWRConfiguration<Data, Error> | undefined): SWRResponse<Data, Error>;
+
+export function useStaticSWR<Data, Error>(
+    ...args: readonly [Key]
+    | readonly [Key, Data | Fetcher<Data> | null]
+    | readonly [Key, Data | Fetcher<Data> | null, SWRConfiguration<Data, Error> | undefined]
+): SWRResponse<Data, Error> {
+  const [key, fetcher, configuration] = args;
+
+  const fetcherFixed = fetcher || configuration?.fetcher;
+  if (fetcherFixed != null) {
+    mutate(key, fetcherFixed);
+  }
+
+  return useSWRImmutable(key, null, configuration);
+}

+ 26 - 23
packages/app/src/test/integration/service/page.test.js

@@ -304,29 +304,32 @@ describe('PageService', () => {
       expect(wrongPage).toBeNull();
     });
 
-    test('rename page with different tree with isRecursively [shallower]', async() => {
-      // setup
-      expect(await Page.findOne({ path: '/level1' })).toBeNull();
-      expect(await Page.findOne({ path: '/level1/level2' })).not.toBeNull();
-      expect(await Page.findOne({ path: '/level1/level2/child' })).not.toBeNull();
-      expect(await Page.findOne({ path: '/level1/level2/level2' })).not.toBeNull();
-      expect(await Page.findOne({ path: '/level1-2021H1' })).not.toBeNull();
-
-      // when
-      //   rename /level1/level2 --> /level1
-      await crowi.pageService.renamePage(parentForRename7, '/level1', testUser1, {}, true);
-
-      // then
-      expect(await Page.findOne({ path: '/level1' })).not.toBeNull();
-      expect(await Page.findOne({ path: '/level1/child' })).not.toBeNull();
-      expect(await Page.findOne({ path: '/level1/level2' })).toBeNull();
-      expect(await Page.findOne({ path: '/level1/level2/child' })).toBeNull();
-      // The changed path is duplicated with the existing path (/level1/level2), so it will not be changed
-      expect(await Page.findOne({ path: '/level1/level2/level2' })).not.toBeNull();
-
-      // Check that pages that are not to be renamed have not been renamed
-      expect(await Page.findOne({ path: '/level1-2021H1' })).not.toBeNull();
-    });
+    /*
+     * TODO: rewrite test when modify rename function
+     */
+    // test('rename page with different tree with isRecursively [shallower]', async() => {
+    //   // setup
+    //   expect(await Page.findOne({ path: '/level1' })).toBeNull();
+    //   expect(await Page.findOne({ path: '/level1/level2' })).not.toBeNull();
+    //   expect(await Page.findOne({ path: '/level1/level2/child' })).not.toBeNull();
+    //   expect(await Page.findOne({ path: '/level1/level2/level2' })).not.toBeNull();
+    //   expect(await Page.findOne({ path: '/level1-2021H1' })).not.toBeNull();
+
+    //   // when
+    //   //   rename /level1/level2 --> /level1
+    //   await crowi.pageService.renamePage(parentForRename7, '/level1', testUser1, {}, true);
+
+    //   // then
+    //   expect(await Page.findOne({ path: '/level1' })).not.toBeNull();
+    //   expect(await Page.findOne({ path: '/level1/child' })).not.toBeNull();
+    //   expect(await Page.findOne({ path: '/level1/level2' })).toBeNull();
+    //   expect(await Page.findOne({ path: '/level1/level2/child' })).toBeNull();
+    //   // The changed path is duplicated with the existing path (/level1/level2), so it will not be changed
+    //   expect(await Page.findOne({ path: '/level1/level2/level2' })).not.toBeNull();
+
+    //   // Check that pages that are not to be renamed have not been renamed
+    //   expect(await Page.findOne({ path: '/level1-2021H1' })).not.toBeNull();
+    // });
   });
 
   describe('rename page', () => {

+ 0 - 3
packages/app/src/utils/swr-utils.ts

@@ -1,9 +1,6 @@
 import { SWRConfiguration } from 'swr';
 
-import axios from './axios';
 
 export const swrGlobalConfiguration: SWRConfiguration = {
-  fetcher: url => axios.get(url).then(res => res.data),
-  revalidateOnFocus: false,
   errorRetryCount: 1,
 };

+ 121 - 3
yarn.lock

@@ -1064,6 +1064,11 @@
     minimatch "^3.0.4"
     strip-json-comments "^3.1.1"
 
+"@gar/promisify@^1.0.1":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.2.tgz#30aa825f11d438671d585bd44e7fd564535fc210"
+  integrity sha512-82cpyJyKRoQoRi+14ibCeGPu0CwypgtBAdBhq1WfvagpCZNKqwXbKwXllYSMG91DhmG4jt9gN8eP6lGOtozuaw==
+
 "@godaddy/terminus@^4.9.0":
   version "4.9.0"
   resolved "https://registry.yarnpkg.com/@godaddy/terminus/-/terminus-4.9.0.tgz#c7de0b45ede05116854d1461832dd05df169f689"
@@ -2136,6 +2141,14 @@
   resolved "https://registry.yarnpkg.com/@npmcli/ci-detect/-/ci-detect-1.3.0.tgz#6c1d2c625fb6ef1b9dea85ad0a5afcbef85ef22a"
   integrity sha512-oN3y7FAROHhrAt7Rr7PnTSwrHrZVRTS2ZbyxeQwSSYD0ifwM3YNgQqbaRmjcWoPyq77MjchusjJDspbzMmip1Q==
 
+"@npmcli/fs@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-1.0.0.tgz#589612cfad3a6ea0feafcb901d29c63fd52db09f"
+  integrity sha512-8ltnOpRR/oJbOp8vaGUnipOi3bqkcW+sLHFlyXIr08OGHmVJLB1Hn7QtGXbYcpVtH1gAYZTlmDXtE4YV0+AMMQ==
+  dependencies:
+    "@gar/promisify" "^1.0.1"
+    semver "^7.3.5"
+
 "@npmcli/git@^2.0.1":
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/@npmcli/git/-/git-2.0.6.tgz#47b97e96b2eede3f38379262fa3bdfa6eae57bf2"
@@ -3696,7 +3709,7 @@ after@0.8.2:
   version "0.8.2"
   resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
 
-agent-base@6:
+agent-base@6, agent-base@^6.0.2:
   version "6.0.2"
   resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
   integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
@@ -5166,6 +5179,30 @@ cacache@^15.0.5:
     tar "^6.0.2"
     unique-filename "^1.1.1"
 
+cacache@^15.2.0:
+  version "15.3.0"
+  resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.3.0.tgz#dc85380fb2f556fe3dda4c719bfa0ec875a7f1eb"
+  integrity sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==
+  dependencies:
+    "@npmcli/fs" "^1.0.0"
+    "@npmcli/move-file" "^1.0.1"
+    chownr "^2.0.0"
+    fs-minipass "^2.0.0"
+    glob "^7.1.4"
+    infer-owner "^1.0.4"
+    lru-cache "^6.0.0"
+    minipass "^3.1.1"
+    minipass-collect "^1.0.2"
+    minipass-flush "^1.0.5"
+    minipass-pipeline "^1.2.2"
+    mkdirp "^1.0.3"
+    p-map "^4.0.0"
+    promise-inflight "^1.0.1"
+    rimraf "^3.0.2"
+    ssri "^8.0.1"
+    tar "^6.0.2"
+    unique-filename "^1.1.1"
+
 cache-base@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
@@ -9760,7 +9797,7 @@ got@^8.3.2:
     url-parse-lax "^3.0.0"
     url-to-options "^1.0.1"
 
-graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.3, graceful-fs@^4.2.4:
+graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.3, graceful-fs@^4.2.4, graceful-fs@^4.2.6:
   version "4.2.8"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
   integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==
@@ -10578,6 +10615,11 @@ inquirer@^7.0.0, inquirer@^7.3.3:
     strip-ansi "^6.0.0"
     through "^2.3.6"
 
+install-artifact-from-github@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/install-artifact-from-github/-/install-artifact-from-github-1.2.0.tgz#adcbd123c16a4337ec44ea76d0ebf253cc16b074"
+  integrity sha512-3OxCPcY55XlVM3kkfIpeCgmoSKnMsz2A3Dbhsq0RXpIknKQmrX1YiznCeW9cD2ItFmDxziA3w6Eg8d80AoL3oA==
+
 internal-slot@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c"
@@ -12770,6 +12812,28 @@ make-fetch-happen@^8.0.9:
     socks-proxy-agent "^5.0.0"
     ssri "^8.0.0"
 
+make-fetch-happen@^9.1.0:
+  version "9.1.0"
+  resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz#53085a09e7971433e6765f7971bf63f4e05cb968"
+  integrity sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==
+  dependencies:
+    agentkeepalive "^4.1.3"
+    cacache "^15.2.0"
+    http-cache-semantics "^4.1.0"
+    http-proxy-agent "^4.0.1"
+    https-proxy-agent "^5.0.0"
+    is-lambda "^1.0.1"
+    lru-cache "^6.0.0"
+    minipass "^3.1.3"
+    minipass-collect "^1.0.2"
+    minipass-fetch "^1.3.2"
+    minipass-flush "^1.0.5"
+    minipass-pipeline "^1.2.4"
+    negotiator "^0.6.2"
+    promise-retry "^2.0.1"
+    socks-proxy-agent "^6.0.0"
+    ssri "^8.0.0"
+
 makeerror@1.0.x:
   version "1.0.11"
   resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c"
@@ -14029,7 +14093,7 @@ negotiator@0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9"
 
-negotiator@0.6.2:
+negotiator@0.6.2, negotiator@^0.6.2:
   version "0.6.2"
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
   integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
@@ -14178,6 +14242,22 @@ node-gyp@^7.1.0:
     tar "^6.0.2"
     which "^2.0.2"
 
+node-gyp@^8.0.0:
+  version "8.4.0"
+  resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-8.4.0.tgz#6e1112b10617f0f8559c64b3f737e8109e5a8338"
+  integrity sha512-Bi/oCm5bH6F+FmzfUxJpPaxMEyIhszULGR3TprmTeku8/dMFcdTcypk120NeZqEt54r1BrgEKtm2jJiuIKE28Q==
+  dependencies:
+    env-paths "^2.2.0"
+    glob "^7.1.4"
+    graceful-fs "^4.2.6"
+    make-fetch-happen "^9.1.0"
+    nopt "^5.0.0"
+    npmlog "^4.1.2"
+    rimraf "^3.0.2"
+    semver "^7.3.5"
+    tar "^6.1.2"
+    which "^2.0.2"
+
 node-int64@^0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
@@ -16673,6 +16753,15 @@ rc@>=1.2.8, rc@^1.0.1, rc@^1.1.6, rc@^1.2.7:
     minimist "^1.2.0"
     strip-json-comments "~2.0.1"
 
+re2@^1.16.0:
+  version "1.16.0"
+  resolved "https://registry.yarnpkg.com/re2/-/re2-1.16.0.tgz#f311eb4865b1296123800ea8e013cec8dab25590"
+  integrity sha512-eizTZL2ZO0ZseLqfD4t3Qd0M3b3Nr0MBWpX81EbPMIud/1d/CSfUIx2GQK8fWiAeHoSekO5EOeFib2udTZLwYw==
+  dependencies:
+    install-artifact-from-github "^1.2.0"
+    nan "^2.14.2"
+    node-gyp "^8.0.0"
+
 react-addons-text-content@^0.0.4:
   version "0.0.4"
   resolved "https://registry.yarnpkg.com/react-addons-text-content/-/react-addons-text-content-0.0.4.tgz#d2e259fdc951d1d8906c08902002108dce8792e5"
@@ -18693,6 +18782,15 @@ socks-proxy-agent@^5.0.0:
     debug "4"
     socks "^2.3.3"
 
+socks-proxy-agent@^6.0.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-6.1.0.tgz#869cf2d7bd10fea96c7ad3111e81726855e285c3"
+  integrity sha512-57e7lwCN4Tzt3mXz25VxOErJKXlPfXmkMLnk310v/jwW20jWRVcgsOit+xNkN3eIEdB47GwnfAEBLacZ/wVIKg==
+  dependencies:
+    agent-base "^6.0.2"
+    debug "^4.3.1"
+    socks "^2.6.1"
+
 socks@^2.3.3:
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/socks/-/socks-2.6.0.tgz#6b984928461d39871b3666754b9000ecf39dfac2"
@@ -18701,6 +18799,14 @@ socks@^2.3.3:
     ip "^1.1.5"
     smart-buffer "^4.1.0"
 
+socks@^2.6.1:
+  version "2.6.1"
+  resolved "https://registry.yarnpkg.com/socks/-/socks-2.6.1.tgz#989e6534a07cf337deb1b1c94aaa44296520d30e"
+  integrity sha512-kLQ9N5ucj8uIcxrDwjm0Jsqk06xdpBjGNQtpXy4Q8/QY2k+fY7nZH8CARy+hkbG+SGAovmzzuauCpBlb8FrnBA==
+  dependencies:
+    ip "^1.1.5"
+    smart-buffer "^4.1.0"
+
 sort-keys@^1.0.0:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
@@ -19734,6 +19840,18 @@ tar@^6.0.2, tar@^6.1.0:
     mkdirp "^1.0.3"
     yallist "^4.0.0"
 
+tar@^6.1.2:
+  version "6.1.11"
+  resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621"
+  integrity sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==
+  dependencies:
+    chownr "^2.0.0"
+    fs-minipass "^2.0.0"
+    minipass "^3.0.0"
+    minizlib "^2.1.1"
+    mkdirp "^1.0.3"
+    yallist "^4.0.0"
+
 tdigest@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.1.tgz#2e3cb2c39ea449e55d1e6cd91117accca4588021"