Explorar o código

Merge branch 'support/apply-nextjs-2' of https://github.com/weseek/growi into imprv/render-tag-index-page-use-nextjs

keigo-h %!s(int64=3) %!d(string=hai) anos
pai
achega
42d3afb1d8

+ 2 - 2
.github/workflows/reusable-app-prod.yml

@@ -60,7 +60,7 @@ jobs:
     - name: Archive production files
       id: archive-prod-files
       run: |
-        tar -cf production.tar \
+        tar -zcf production.tar.gz \
           package.json \
           packages/app/.next \
           packages/app/config \
@@ -70,7 +70,7 @@ jobs:
           packages/app/.env.production* \
           packages/*/package.json \
           packages/*/dist
-        echo ::set-output name=file::production.tar
+        echo ::set-output name=file::production.tar.gz
 
     - name: Upload production files as artifact
       uses: actions/upload-artifact@v3

+ 18 - 6
packages/app/src/pages/[[...path]].page.tsx

@@ -7,6 +7,7 @@ import {
   IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision, isClient, isIPageInfoForEntity, isServer, IUser, IUserHasId, pagePathUtils, pathUtils,
 } from '@growi/core';
 import ExtensibleCustomError from 'extensible-custom-error';
+import mongoose from 'mongoose';
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -29,6 +30,7 @@ import { RendererConfig } from '~/interfaces/services/renderer';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { PageModel, PageDocument } from '~/server/models/page';
+import { PageRedirectModel } from '~/server/models/page-redirect';
 import UserUISettings from '~/server/models/user-ui-settings';
 import Xss from '~/services/xss';
 import { useSWRxCurrentPage, useSWRxPageInfo } from '~/stores/page';
@@ -41,11 +43,11 @@ import loggerFactory from '~/utils/logger';
 
 // import GrowiSubNavigation from '../client/js/components/Navbar/GrowiSubNavigation';
 // import GrowiSubNavigationSwitcher from '../client/js/components/Navbar/GrowiSubNavigationSwitcher';
+import ForbiddenPage from '../components/ForbiddenPage';
 import { BasicLayout } from '../components/Layout/BasicLayout';
 import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
-import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import { NotCreatablePage } from '../components/NotCreatablePage';
-import ForbiddenPage from '../components/ForbiddenPage';
+import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 
 // import { serializeUserSecurely } from '../server/models/serializers/user-serializer';
 // import PageStatusAlert from '../client/js/components/PageStatusAlert';
@@ -121,8 +123,7 @@ type Props = CommonProps & {
 
   pageWithMeta: IPageToShowRevisionWithMeta,
   // pageUser?: any,
-  // redirectTo?: string;
-  // redirectFrom?: string;
+  redirectFrom?: string;
 
   // shareLinkId?: string;
   isLatestRevision?: boolean
@@ -355,17 +356,28 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
   const { revisionId } = req.query;
 
   const Page = crowi.model('Page') as PageModel;
+  const PageRedirect = mongoose.model('PageRedirect') as PageRedirectModel;
   const { pageService } = crowi;
 
-  const { currentPathname } = props;
+  let currentPathname = props.currentPathname;
 
   const pageId = getPageIdFromPathname(currentPathname);
   const isPermalink = _isPermalink(currentPathname);
 
   const { user } = req;
 
-  // check whether the specified page path hits to multiple pages
   if (!isPermalink) {
+    // check redirects
+    const chains = await PageRedirect.retrievePageRedirectEndpoints(currentPathname);
+    if (chains != null) {
+      // overwrite currentPathname
+      currentPathname = chains.end.toPath;
+      props.currentPathname = currentPathname;
+      // set redirectFrom
+      props.redirectFrom = chains.start.fromPath;
+    }
+
+    // check whether the specified page path hits to multiple pages
     const count = await Page.countByPathAndViewer(currentPathname, user, null, true);
     if (count > 1) {
       throw new MultiplePagesHitsError(currentPathname);

+ 133 - 4
packages/app/src/server/models/page-redirect.ts

@@ -4,19 +4,37 @@ import {
   Schema, Model, Document,
 } from 'mongoose';
 
+import loggerFactory from '~/utils/logger';
+
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-export interface IPageRedirect {
+
+const logger = loggerFactory('growi:models:page-redirects');
+
+
+export type IPageRedirect = {
   fromPath: string,
   toPath: string,
 }
 
+export type IPageRedirectEndpoints = {
+  start: IPageRedirect,
+  end: IPageRedirect,
+}
+
 export interface PageRedirectDocument extends IPageRedirect, Document {}
 
 export interface PageRedirectModel extends Model<PageRedirectDocument> {
-  [x:string]: any // TODO: improve type
+  retrievePageRedirectEndpoints(fromPath: string): Promise<IPageRedirectEndpoints>
+  removePageRedirectsByToPath(toPath: string): Promise<void>
 }
 
+const CHAINS_FIELD_NAME = 'chains';
+const DEPTH_FIELD_NAME = 'depth';
+type IPageRedirectWithChains = PageRedirectDocument & {
+  [CHAINS_FIELD_NAME]: (PageRedirectDocument & { [DEPTH_FIELD_NAME]: number })[]
+};
+
 const schema = new Schema<PageRedirectDocument, PageRedirectModel>({
   fromPath: {
     type: String, required: true, unique: true, index: true,
@@ -24,9 +42,120 @@ const schema = new Schema<PageRedirectDocument, PageRedirectModel>({
   toPath: { type: String, required: true },
 });
 
-schema.statics.removePageRedirectByToPath = async function(toPath: string): Promise<void> {
-  await this.deleteMany({ toPath });
+schema.statics.retrievePageRedirectEndpoints = async function(fromPath: string): Promise<IPageRedirectEndpoints|null> {
+  const aggResult: IPageRedirectWithChains[] = await this.aggregate([
+    { $match: { fromPath } },
+    {
+      $graphLookup: {
+        from: 'pageredirects',
+        startWith: '$toPath',
+        connectFromField: 'toPath',
+        connectToField: 'fromPath',
+        as: CHAINS_FIELD_NAME,
+        depthField: DEPTH_FIELD_NAME,
+      },
+    },
+  ]);
+  /* ---------- aggResult example ----------
+  {
+    "_id" : ObjectId("62e5650d6134d37aa0935e6d"),
+    "fromPath" : "/page1",
+    "toPath" : "/page2",
+    "chains" : [
+        {
+            "_id" : ObjectId("62e5651b6134d37aa0935e7a"),
+            "fromPath" : "/page2",
+            "toPath" : "/page3",
+            "depth" : NumberLong(0)
+        },
+        {
+            "_id" : ObjectId("62e565256134d37aa0935e80"),
+            "fromPath" : "/page3",
+            "toPath" : "/Sandbox",
+            "depth" : NumberLong(1)
+        }
+    ]
+  }
+  */
+
+  if (aggResult.length === 0) {
+    return null;
+  }
+
+  if (aggResult.length > 1) {
+    logger.warn(`Although two or more PageRedirect documents starts from '${fromPath}' exists, The first one is used.`);
+  }
+
+  const redirectWithChains = aggResult[0];
+
+  // sort chains in desc
+  const sortedChains = redirectWithChains[CHAINS_FIELD_NAME].sort((a, b) => b[DEPTH_FIELD_NAME] - a[DEPTH_FIELD_NAME]);
+
+  const start = { fromPath: redirectWithChains.fromPath, toPath: redirectWithChains.toPath };
+  const end = sortedChains.length === 0
+    ? start
+    : sortedChains[0];
+
+  return { start, end };
+};
+
+schema.statics.removePageRedirectsByToPath = async function(toPath: string): Promise<void> {
+  const aggResult: IPageRedirectWithChains[] = await this.aggregate([
+    { $match: { toPath } },
+    {
+      $graphLookup: {
+        from: 'pageredirects',
+        startWith: '$fromPath',
+        connectFromField: 'fromPath',
+        connectToField: 'toPath',
+        as: CHAINS_FIELD_NAME,
+      },
+    },
+  ]);
+  /* ---------- aggResult example ----------
+  // 1
+  {
+    "_id" : ObjectId("62e565256134d37aa0935e80"),
+    "fromPath" : "/page3",
+    "toPath" : "/page4",
+    "chains" : [
+        {
+            "_id" : ObjectId("62e5651b6134d37aa0935e7a"),
+            "fromPath" : "/page2",
+            "toPath" : "/page3",
+            "depth" : NumberLong(0)
+        },
+        {
+            "_id" : ObjectId("62e5650d6134d37aa0935e6d"),
+            "fromPath" : "/page1",
+            "toPath" : "/page2",
+            "depth" : NumberLong(1)
+        }
+    ]
+  }
+  // 2
+  {
+    "_id" : ObjectId("62e5937a6134d37aa0936405"),
+    "fromPath" : "/org/page4",
+    "toPath" : "/page4",
+    "chains" : []
+  }
+  */
+
+  if (aggResult.length === 0) {
+    return;
+  }
+
+  const idsToRemove = aggResult
+    .map((redirectWithChains) => {
+      return [
+        redirectWithChains._id,
+        redirectWithChains[CHAINS_FIELD_NAME].map(doc => doc._id),
+      ].flat();
+    })
+    .flat();
 
+  await this.deleteMany({ _id: { $in: idsToRemove } });
   return;
 };
 

+ 1 - 1
packages/app/src/server/routes/page.js

@@ -1476,7 +1476,7 @@ module.exports = function(crowi, app) {
     const path = req.body.path;
 
     try {
-      await PageRedirect.removePageRedirectByToPath(path);
+      await PageRedirect.removePageRedirectsByToPath(path);
       logger.debug('Redirect Page deleted', path);
     }
     catch (err) {

+ 111 - 0
packages/app/test/integration/models/page-redirect.test.js

@@ -0,0 +1,111 @@
+import mongoose from 'mongoose';
+
+import { IPageRedirect, PageRedirectModel } from '../../../src/server/models/page-redirect';
+import { getInstance } from '../setup-crowi';
+
+describe('PageRedirect', () => {
+  // eslint-disable-next-line no-unused-vars
+  let crowi;
+  let PageRedirect;
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+
+    PageRedirect = mongoose.model('PageRedirect');
+  });
+
+  beforeEach(async() => {
+    // clear collection
+    await PageRedirect.deleteMany({});
+  });
+
+  describe('.removePageRedirectsByToPath', () => {
+    test('works fine', async() => {
+      // setup:
+      await PageRedirect.insertMany([
+        { fromPath: '/org/path1', toPath: '/path1' },
+        { fromPath: '/org/path2', toPath: '/path2' },
+        { fromPath: '/org/path3', toPath: '/path3' },
+        { fromPath: '/org/path33', toPath: '/org/path333' },
+        { fromPath: '/org/path333', toPath: '/path3' },
+      ]);
+      expect(await PageRedirect.findOne({ fromPath: '/org/path1' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path2' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path3' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path33' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path333' })).not.toBeNull();
+
+      // when:
+      // remove all documents that have { toPath: '/path/3' }
+      await PageRedirect.removePageRedirectsByToPath('/path3');
+
+      // then:
+      expect(await PageRedirect.findOne({ fromPath: '/org/path1' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path2' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path3' })).toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path33' })).toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path333' })).toBeNull();
+    });
+  });
+
+  describe('.retrievePageRedirectEndpoints', () => {
+    test('shoud return null when data is not found', async() => {
+      // setup:
+      expect(await PageRedirect.findOne({ fromPath: '/path1' })).toBeNull();
+
+      // when:
+      // retrieve
+      const endpoints = await PageRedirect.retrievePageRedirectEndpoints('/path1');
+
+      // then:
+      expect(endpoints).toBeNull();
+    });
+
+    test('shoud return IPageRedirectEnds (start and end is the same)', async() => {
+      // setup:
+      await PageRedirect.insertMany([
+        { fromPath: '/path1', toPath: '/path2' },
+      ]);
+      expect(await PageRedirect.findOne({ fromPath: '/path1' })).not.toBeNull();
+
+      // when:
+      // retrieve
+      const endpoints = await PageRedirect.retrievePageRedirectEndpoints('/path1');
+
+      // then:
+      expect(endpoints).not.toBeNull();
+      expect(endpoints.start).not.toBeNull();
+      expect(endpoints.start.fromPath).toEqual('/path1');
+      expect(endpoints.start.toPath).toEqual('/path2');
+      expect(endpoints.end).not.toBeNull();
+      expect(endpoints.end.fromPath).toEqual('/path1');
+      expect(endpoints.end.toPath).toEqual('/path2');
+    });
+
+    test('shoud return IPageRedirectEnds', async() => {
+      // setup:
+      await PageRedirect.insertMany([
+        { fromPath: '/path1', toPath: '/path2' },
+        { fromPath: '/path2', toPath: '/path3' },
+        { fromPath: '/path3', toPath: '/path4' },
+      ]);
+      expect(await PageRedirect.findOne({ fromPath: '/path1' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/path2' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/path3' })).not.toBeNull();
+
+      // when:
+      // retrieve
+      const endpoints = await PageRedirect.retrievePageRedirectEndpoints('/path1');
+
+      // then:
+      expect(endpoints).not.toBeNull();
+      expect(endpoints.start).not.toBeNull();
+      expect(endpoints.start.fromPath).toEqual('/path1');
+      expect(endpoints.start.toPath).toEqual('/path2');
+      expect(endpoints.end).not.toBeNull();
+      expect(endpoints.end.fromPath).toEqual('/path3');
+      expect(endpoints.end.toPath).toEqual('/path4');
+    });
+  });
+
+});