Преглед изворни кода

Merge branch 'feat/pt-dev-2' into feat/add-items-to-pt

Taichi Masuyama пре 4 година
родитељ
комит
7c95a40d40

+ 2 - 1
packages/app/package.json

@@ -59,8 +59,8 @@
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
     "@growi/codemirror-textlint": "^4.4.10-RC.0",
     "@growi/codemirror-textlint": "^4.4.10-RC.0",
     "@growi/plugin-attachment-refs": "^4.4.10-RC.0",
     "@growi/plugin-attachment-refs": "^4.4.10-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^4.4.10-RC.0",
     "@growi/plugin-lsx": "^4.4.10-RC.0",
     "@growi/plugin-lsx": "^4.4.10-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.4.10-RC.0",
     "@growi/slack": "^4.4.10-RC.0",
     "@growi/slack": "^4.4.10-RC.0",
     "@promster/express": "^5.1.0",
     "@promster/express": "^5.1.0",
     "@promster/server": "^6.0.3",
     "@promster/server": "^6.0.3",
@@ -130,6 +130,7 @@
     "passport-saml": "^2.2.0",
     "passport-saml": "^2.2.0",
     "passport-twitter": "^1.0.4",
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
     "prom-client": "^13.0.0",
+    "re2": "^1.16.0",
     "react-card-flip": "^1.0.10",
     "react-card-flip": "^1.0.10",
     "react-image-crop": "^8.3.0",
     "react-image-crop": "^8.3.0",
     "reconnecting-websocket": "^4.4.0",
     "reconnecting-websocket": "^4.4.0",

+ 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;

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

@@ -1,14 +1,31 @@
+import { Ref } from './common';
 import { IUser } from './user';
 import { IUser } from './user';
 import { IRevision } from './revision';
 import { IRevision } from './revision';
 import { ITag } from './tag';
 import { ITag } from './tag';
 
 
+
 export type IPage = {
 export type IPage = {
   path: string,
   path: string,
   status: string,
   status: string,
-  revision: IRevision,
-  tags: ITag[],
-  creator: IUser,
+  revision: Ref<IRevision>,
+  tags: Ref<ITag>[],
+  creator: Ref<IUser>,
   createdAt: Date,
   createdAt: Date,
   updatedAt: 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,
 }
 }

+ 9 - 25
packages/app/src/server/models/obsolete-page.js

@@ -219,6 +219,15 @@ export class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
+  /*
+   * Add this condition when get any ancestor pages including the target's parent
+   */
+  addConditionToSortAncestorPages() {
+    this.query = this.query.sort('-path');
+
+    return this;
+  }
+
   addConditionToListByPathsArray(paths) {
   addConditionToListByPathsArray(paths) {
     this.query = this.query
     this.query = this.query
       .and({
       .and({
@@ -556,31 +565,6 @@ export const getPageSchema = (crowi) => {
     return this.findOne({ path });
     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 {string} path Page path
    * @param {User} user User instance
    * @param {User} user User instance

+ 141 - 5
packages/app/src/server/models/page.ts

@@ -6,11 +6,12 @@ import mongoose, {
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 import uniqueValidator from 'mongoose-unique-validator';
 import nodePath from 'path';
 import nodePath from 'path';
+import RE2 from 're2';
 
 
 import { getOrCreateModel, pagePathUtils } from '@growi/core';
 import { getOrCreateModel, pagePathUtils } from '@growi/core';
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 import Crowi from '../crowi';
-import { IPage } from '~/interfaces/page';
+import { IPage } from '../../interfaces/page';
 import { getPageSchema, PageQueryBuilder } from './obsolete-page';
 import { getPageSchema, PageQueryBuilder } from './obsolete-page';
 
 
 const { isTopPage } = pagePathUtils;
 const { isTopPage } = pagePathUtils;
@@ -35,6 +36,10 @@ export interface PageDocument extends IPage, Document {}
 export interface PageModel extends Model<PageDocument> {
 export interface PageModel extends Model<PageDocument> {
   createEmptyPagesByPaths(paths: string[]): Promise<void>
   createEmptyPagesByPaths(paths: string[]): Promise<void>
   getParentIdAndFillAncestors(path: string): Promise<string | null>
   getParentIdAndFillAncestors(path: string): Promise<string | null>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?): Promise<PageDocument[]>
+  findSiblingsByPathAndViewer(path: string | null, user, userGroups?): Promise<PageDocument[]>
+  findAncestorsByPathOrId(pathOrId: string): Promise<PageDocument[]>
+  findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
 }
 }
 
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
@@ -79,14 +84,33 @@ schema.plugin(uniqueValidator);
  * Methods
  * Methods
  */
  */
 const collectAncestorPaths = (path: string, ancestorPaths: string[] = []): string[] => {
 const collectAncestorPaths = (path: string, ancestorPaths: string[] = []): string[] => {
+  if (isTopPage(path)) return ancestorPaths;
+
   const parentPath = nodePath.dirname(path);
   const parentPath = nodePath.dirname(path);
   ancestorPaths.push(parentPath);
   ancestorPaths.push(parentPath);
+  return collectAncestorPaths(parentPath, ancestorPaths);
+};
 
 
-  if (!isTopPage(path)) return collectAncestorPaths(parentPath, ancestorPaths);
+const hasSlash = (str: string): boolean => {
+  return str.includes('/');
+};
 
 
-  return ancestorPaths;
+/*
+ * Generate RE2 instance for one level lower path
+ */
+const generateChildrenRegExp = (path: string): RE2 => {
+  // https://regex101.com/r/iu1vYF/1
+  // ex. / OR /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}(\\/[^/]+)\\/?$`);
 };
 };
 
 
+/*
+ * Create empty pages if the page in paths didn't exist
+ */
 schema.statics.createEmptyPagesByPaths = async function(paths: string[]): Promise<void> {
 schema.statics.createEmptyPagesByPaths = async function(paths: string[]): Promise<void> {
   // find existing parents
   // find existing parents
   const builder = new PageQueryBuilder(this.find({}, { _id: 0, path: 1 }));
   const builder = new PageQueryBuilder(this.find({}, { _id: 0, path: 1 }));
@@ -110,7 +134,14 @@ schema.statics.createEmptyPagesByPaths = async function(paths: string[]): Promis
   }
   }
 };
 };
 
 
-schema.statics.getParentIdAndFillAncestors = async function(path: string): Promise<string | null> {
+/*
+ * 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 parentPath = nodePath.dirname(path);
 
 
   const parent = await this.findOne({ path: parentPath }); // find the oldest parent which must always be the true parent
   const parent = await this.findOne({ path: parentPath }); // find the oldest parent which must always be the true parent
@@ -127,6 +158,7 @@ schema.statics.getParentIdAndFillAncestors = async function(path: string): Promi
   const builder = new PageQueryBuilder(this.find({}, { _id: 1, path: 1 }));
   const builder = new PageQueryBuilder(this.find({}, { _id: 1, path: 1 }));
   const ancestors = await builder
   const ancestors = await builder
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToListByPathsArray(ancestorPaths)
+    .addConditionToSortAncestorPages()
     .query
     .query
     .lean()
     .lean()
     .exec();
     .exec();
@@ -157,12 +189,116 @@ schema.statics.getParentIdAndFillAncestors = async function(path: string): Promi
   return parentId;
   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 the siblings including the target page. When top page, it returns /any_level1 pages
+ */
+schema.statics.findSiblingsByPathAndViewer = async function(path: string | null, user, userGroups): Promise<PageDocument[]> {
+  if (path == null) {
+    throw new Error('path is required.');
+  }
+
+  const _parentPath = nodePath.dirname(path);
+  const parentPath = isTopPage(_parentPath) ? '' : _parentPath;
+
+  const regexp = generateChildrenRegExp(parentPath);
+
+  const queryBuilder = new PageQueryBuilder(this.find({ path: { $regex: regexp.source, $options: regexp.flags } }));
+  await addViewerCondition(queryBuilder, user, userGroups);
+
+  return queryBuilder.query.lean().exec();
+};
+
+/*
+ * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result
+ */
+schema.statics.findAncestorsByPathOrId = async function(pathOrId: string): Promise<PageDocument[]> {
+  let path;
+  if (!hasSlash(pathOrId)) {
+    const _id = pathOrId;
+    const page = await this.findOne({ _id });
+    if (page == null) throw Error('Page not found.');
+
+    path = page.path;
+  }
+  else {
+    path = pathOrId;
+  }
+
+  const ancestorPaths = collectAncestorPaths(path);
+
+  // Do not populate
+  const queryBuilder = new PageQueryBuilder(this.find());
+  const _ancestors: PageDocument[] = await queryBuilder
+    .addConditionToListByPathsArray(ancestorPaths)
+    .addConditionToSortAncestorPages()
+    .query
+    .lean()
+    .exec();
+
+  // no same path pages
+  const ancestorsMap = new Map<string, PageDocument>();
+  _ancestors.forEach(page => ancestorsMap.set(page.path, page));
+  const ancestors = Array.from(ancestorsMap.values());
+
+  return ancestors;
+};
+
+/*
+ * 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 = generateChildrenRegExp(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();
+};
+
+
+/*
+ * Merge obsolete page model methods and define new methods which depend on crowi instance
+ */
 export default (crowi: Crowi): any => {
 export default (crowi: Crowi): any => {
   // add old page schema methods
   // add old page schema methods
   const pageSchema = getPageSchema(crowi);
   const pageSchema = getPageSchema(crowi);
   schema.methods = { ...pageSchema.methods, ...schema.methods };
   schema.methods = { ...pageSchema.methods, ...schema.methods };
   schema.statics = { ...pageSchema.statics, ...schema.statics };
   schema.statics = { ...pageSchema.statics, ...schema.statics };
 
 
-
   return getOrCreateModel<PageDocument, PageModel>('Page', schema);
   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 loggerFactory from '~/utils/logger';
 
 
+import pageListing from './page-listing';
+
 const logger = loggerFactory('growi:routes:apiv3'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
@@ -41,6 +43,8 @@ module.exports = (crowi) => {
   router.use('/pages', require('./pages')(crowi));
   router.use('/pages', require('./pages')(crowi));
   router.use('/revisions', require('./revisions')(crowi));
   router.use('/revisions', require('./revisions')(crowi));
 
 
+  router.use('/page-listing', pageListing(crowi));
+
   router.use('/share-links', require('./share-links')(crowi));
   router.use('/share-links', require('./share-links')(crowi));
 
 
   router.use('/bookmarks', require('./bookmarks')(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
+}

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

@@ -0,0 +1,121 @@
+import express, { Request, Router } from 'express';
+import { pagePathUtils } from '@growi/core';
+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 { isTopPage } = pagePathUtils;
+
+const logger = loggerFactory('growi:routes:apiv3:page-tree');
+
+/*
+ * Types & Interfaces
+ */
+interface AuthorizedRequest extends Request {
+  user?: any
+}
+
+/*
+ * Validators
+ */
+const validator = {
+  pageIdAndPathRequired: [
+    query('id').isMongoId().withMessage('id is required'),
+    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('/siblings', accessTokenParser, loginRequiredStrictly, ...validator.pageIdAndPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
+    const { id, path } = req.query;
+
+    const Page: PageModel = crowi.model('Page');
+
+    let siblings: PageDocument[];
+    let target: PageDocument;
+    try {
+      siblings = await Page.findSiblingsByPathAndViewer(path as string, req.user);
+
+      target = siblings.filter(page => page._id.toString() === id)?.[0];
+      if (target == null) {
+        throw Error('Target must exist.');
+      }
+    }
+    catch (err) {
+      logger.error('Error occurred while finding pages.', err);
+      return res.apiv3Err(new ErrorV3('Error occurred while finding pages.'));
+    }
+
+    if (isTopPage(path as string)) {
+      siblings = siblings.filter(page => !isTopPage(page.path));
+    }
+
+    return res.apiv3({ target, siblings });
+  });
+
+  /*
+   * In most cases, using path should be prioritized
+   */
+  // eslint-disable-next-line max-len
+  router.get('/ancestors', accessTokenParser, loginRequiredStrictly, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
+    const { id, path } = req.query;
+
+    const Page: PageModel = crowi.model('Page');
+
+    let ancestors: PageDocument[];
+    try {
+      ancestors = await Page.findAncestorsByPathOrId((path || id) as string);
+
+      if (ancestors.length === 0 && !isTopPage(path as string)) {
+        throw Error('Ancestors must have at least one page.');
+      }
+    }
+    catch (err) {
+      logger.error('Error occurred while finding pages.', err);
+      return res.apiv3Err(new ErrorV3('Error occurred while finding pages.'));
+    }
+
+    return res.apiv3({ ancestors });
+  });
+
+  /*
+   * 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({ 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
   // my drafts
   app.get('/me/drafts'                , loginRequiredStrictly, me.drafts.list);
   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('/_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/:id([0-9a-z]{24})' , certifySharedFile , loginRequired, attachment.api.get);
   app.get('/attachment/profile/:id([0-9a-z]{24})' , 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('/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);
 
 
 };
 };

+ 39 - 13
packages/app/src/server/routes/page.js

@@ -1,4 +1,5 @@
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
+import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import UpdatePost from '../models/update-post';
 import UpdatePost from '../models/update-post';
@@ -279,10 +280,10 @@ module.exports = function(crowi, app) {
   }
   }
 
 
   async function showPageForPresentation(req, res, next) {
   async function showPageForPresentation(req, res, next) {
-    const path = getPathFromRequest(req);
+    const id = req.params.id;
     const { revisionId } = req.query;
     const { revisionId } = req.query;
 
 
-    let page = await Page.findByPathAndViewer(path, req.user);
+    let page = await Page.findByIdAndViewer(id, req.user);
 
 
     if (page == null) {
     if (page == null) {
       next();
       next();
@@ -333,22 +334,25 @@ module.exports = function(crowi, app) {
   }
   }
 
 
   async function showPageForGrowiBehavior(req, res, next) {
   async function showPageForGrowiBehavior(req, res, next) {
-    const path = getPathFromRequest(req);
+    const id = req.params.id;
     const revisionId = req.query.revision;
     const revisionId = req.query.revision;
 
 
-    let page = await Page.findByPathAndViewer(path, req.user);
+    let page = await Page.findByIdAndViewer(id, req.user);
 
 
     if (page == null) {
     if (page == null) {
       // check the page is forbidden or just does not exist.
       // 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();
       return next();
     }
     }
+
+    const { path } = page; // this must exist
+
     if (page.redirectTo) {
     if (page.redirectTo) {
       debug(`Redirect to '${page.redirectTo}'`);
       debug(`Redirect to '${page.redirectTo}'`);
       return res.redirect(`${encodeURI(page.redirectTo)}?redirectFrom=${encodeURIComponent(path)}`);
       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 limit = 50;
     const offset = parseInt(req.query.offset) || 0;
     const offset = parseInt(req.query.offset) || 0;
@@ -373,7 +377,7 @@ module.exports = function(crowi, app) {
     const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: page._id });
     const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: page._id });
     renderVars.sharelinksNumber = sharelinksNumber;
     renderVars.sharelinksNumber = sharelinksNumber;
 
 
-    if (isUserPage(page.path)) {
+    if (isUserPage(path)) {
       // change template
       // change template
       view = 'layout-growi/user_page';
       view = 'layout-growi/user_page';
       await addRenderVarsForUserPage(renderVars, page);
       await addRenderVarsForUserPage(renderVars, page);
@@ -558,16 +562,38 @@ module.exports = function(crowi, app) {
   /**
   /**
    * redirector
    * 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);
   };
   };
 
 
 
 

+ 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 %}

+ 121 - 3
yarn.lock

@@ -1093,6 +1093,11 @@
     minimatch "^3.0.4"
     minimatch "^3.0.4"
     strip-json-comments "^3.1.1"
     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":
 "@godaddy/terminus@^4.9.0":
   version "4.9.0"
   version "4.9.0"
   resolved "https://registry.yarnpkg.com/@godaddy/terminus/-/terminus-4.9.0.tgz#c7de0b45ede05116854d1461832dd05df169f689"
   resolved "https://registry.yarnpkg.com/@godaddy/terminus/-/terminus-4.9.0.tgz#c7de0b45ede05116854d1461832dd05df169f689"
@@ -2165,6 +2170,14 @@
   resolved "https://registry.yarnpkg.com/@npmcli/ci-detect/-/ci-detect-1.3.0.tgz#6c1d2c625fb6ef1b9dea85ad0a5afcbef85ef22a"
   resolved "https://registry.yarnpkg.com/@npmcli/ci-detect/-/ci-detect-1.3.0.tgz#6c1d2c625fb6ef1b9dea85ad0a5afcbef85ef22a"
   integrity sha512-oN3y7FAROHhrAt7Rr7PnTSwrHrZVRTS2ZbyxeQwSSYD0ifwM3YNgQqbaRmjcWoPyq77MjchusjJDspbzMmip1Q==
   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":
 "@npmcli/git@^2.0.1":
   version "2.0.6"
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/@npmcli/git/-/git-2.0.6.tgz#47b97e96b2eede3f38379262fa3bdfa6eae57bf2"
   resolved "https://registry.yarnpkg.com/@npmcli/git/-/git-2.0.6.tgz#47b97e96b2eede3f38379262fa3bdfa6eae57bf2"
@@ -3729,7 +3742,7 @@ after@0.8.2:
   version "0.8.2"
   version "0.8.2"
   resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
   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"
   version "6.0.2"
   resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
   resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
   integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
   integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
@@ -5216,6 +5229,30 @@ cacache@^15.0.5:
     tar "^6.0.2"
     tar "^6.0.2"
     unique-filename "^1.1.1"
     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:
 cache-base@^1.0.1:
   version "1.0.1"
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
   resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
@@ -9918,7 +9955,7 @@ got@^8.3.2:
     url-parse-lax "^3.0.0"
     url-parse-lax "^3.0.0"
     url-to-options "^1.0.1"
     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"
   version "4.2.8"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
   integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==
   integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==
@@ -10746,6 +10783,11 @@ inquirer@^7.0.0, inquirer@^7.3.3:
     strip-ansi "^6.0.0"
     strip-ansi "^6.0.0"
     through "^2.3.6"
     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:
 internal-slot@^1.0.3:
   version "1.0.3"
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c"
   resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c"
@@ -12967,6 +13009,28 @@ make-fetch-happen@^8.0.9:
     socks-proxy-agent "^5.0.0"
     socks-proxy-agent "^5.0.0"
     ssri "^8.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:
 makeerror@1.0.x:
   version "1.0.11"
   version "1.0.11"
   resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c"
   resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c"
@@ -14234,7 +14298,7 @@ negotiator@0.6.1:
   version "0.6.1"
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9"
   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"
   version "0.6.2"
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
   integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
   integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
@@ -14401,6 +14465,22 @@ node-gyp@^7.1.0:
     tar "^6.0.2"
     tar "^6.0.2"
     which "^2.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:
 node-int64@^0.4.0:
   version "0.4.0"
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
   resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
@@ -17027,6 +17107,15 @@ rc@>=1.2.8, rc@^1.0.1, rc@^1.1.6, rc@^1.2.7:
     minimist "^1.2.0"
     minimist "^1.2.0"
     strip-json-comments "~2.0.1"
     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:
 react-addons-text-content@^0.0.4:
   version "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"
   resolved "https://registry.yarnpkg.com/react-addons-text-content/-/react-addons-text-content-0.0.4.tgz#d2e259fdc951d1d8906c08902002108dce8792e5"
@@ -19100,6 +19189,15 @@ socks-proxy-agent@^5.0.0:
     debug "4"
     debug "4"
     socks "^2.3.3"
     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:
 socks@^2.3.3:
   version "2.6.0"
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/socks/-/socks-2.6.0.tgz#6b984928461d39871b3666754b9000ecf39dfac2"
   resolved "https://registry.yarnpkg.com/socks/-/socks-2.6.0.tgz#6b984928461d39871b3666754b9000ecf39dfac2"
@@ -19108,6 +19206,14 @@ socks@^2.3.3:
     ip "^1.1.5"
     ip "^1.1.5"
     smart-buffer "^4.1.0"
     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:
 sort-keys@^1.0.0:
   version "1.1.2"
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
   resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
@@ -20224,6 +20330,18 @@ tar@^6.1.0:
     mkdirp "^1.0.3"
     mkdirp "^1.0.3"
     yallist "^4.0.0"
     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:
 tdigest@^0.1.1:
   version "0.1.1"
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.1.tgz#2e3cb2c39ea449e55d1e6cd91117accca4588021"
   resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.1.tgz#2e3cb2c39ea449e55d1e6cd91117accca4588021"