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

Merge remote-tracking branch 'origin/master' into dev/7.0.x

Yuki Takei пре 2 година
родитељ
комит
690a51dfdb

+ 1 - 0
apps/app/.eslintrc.js

@@ -27,6 +27,7 @@ module.exports = {
       },
       },
     ]],
     ]],
     '@typescript-eslint/no-var-requires': 'off',
     '@typescript-eslint/no-var-requires': 'off',
+    '@typescript-eslint/consistent-type-imports': 'warn',
 
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     '@typescript-eslint/no-use-before-define': ['warn'],
     '@typescript-eslint/no-use-before-define': ['warn'],

+ 6 - 2
apps/app/package.json

@@ -35,11 +35,12 @@
     "prelint:swagger2openapi": "yarn openapi:v3",
     "prelint:swagger2openapi": "yarn openapi:v3",
     "test": "run-p test:*",
     "test": "run-p test:*",
     "test:jest": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
     "test:jest": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
-    "test:vitest": "run-p vitest:run vitest:run:integ",
+    "test:vitest": "run-p vitest:run vitest:run:integ vitest:run:components",
     "jest:run": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "jest:run": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
     "reg:run": "reg-suit run",
     "vitest:run": "vitest run config src --coverage",
     "vitest:run": "vitest run config src --coverage",
     "vitest:run:integ": "vitest run -c vitest.config.integ.ts src --coverage",
     "vitest:run:integ": "vitest run -c vitest.config.integ.ts src --coverage",
+    "vitest:run:components": "vitest run -c vitest.config.components.ts src --coverage",
     "previtest:run:integ": "vitest run -c test-with-vite/download-mongo-binary/vitest.config.ts test-with-vite/download-mongo-binary",
     "previtest:run:integ": "vitest run -c test-with-vite/download-mongo-binary/vitest.config.ts test-with-vite/download-mongo-binary",
     "//// misc": "",
     "//// misc": "",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
@@ -206,7 +207,6 @@
     "universal-bunyan": "^0.9.2",
     "universal-bunyan": "^0.9.2",
     "unstated": "^2.1.1",
     "unstated": "^2.1.1",
     "unzip-stream": "^0.3.1",
     "unzip-stream": "^0.3.1",
-    "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "url-join": "^4.0.0",
     "usehooks-ts": "^2.6.0",
     "usehooks-ts": "^2.6.0",
     "validator": "^13.7.0",
     "validator": "^13.7.0",
@@ -226,12 +226,15 @@
     "@next/bundle-analyzer": "^13.2.3",
     "@next/bundle-analyzer": "^13.2.3",
     "@swc-node/jest": "^1.6.2",
     "@swc-node/jest": "^1.6.2",
     "@swc/jest": "^0.2.24",
     "@swc/jest": "^0.2.24",
+    "@testing-library/react": "^14.1.2",
+    "@testing-library/user-event": "^14.5.2",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",
     "@types/jest": "^29.5.2",
     "@types/jest": "^29.5.2",
     "@types/react-scroll": "^1.8.4",
     "@types/react-scroll": "^1.8.4",
     "@types/throttle-debounce": "^5.0.1",
     "@types/throttle-debounce": "^5.0.1",
     "@types/url-join": "^4.0.2",
     "@types/url-join": "^4.0.2",
     "@types/unzip-stream": "^0.3.4",
     "@types/unzip-stream": "^0.3.4",
+    "@vitejs/plugin-react": "^4.2.1",
     "@vitest/coverage-v8": "^0.34.6",
     "@vitest/coverage-v8": "^0.34.6",
     "autoprefixer": "^9.0.0",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
     "babel-loader": "^8.2.5",
@@ -247,6 +250,7 @@
     "font-awesome": "^4.7.0",
     "font-awesome": "^4.7.0",
     "fslightbox-react": "^1.7.6",
     "fslightbox-react": "^1.7.6",
     "handsontable": "=6.2.2",
     "handsontable": "=6.2.2",
+    "happy-dom": "^13.2.0",
     "i18next-hmr": "^1.11.0",
     "i18next-hmr": "^1.11.0",
     "jest": "^29.5.0",
     "jest": "^29.5.0",
     "jest-date-mock": "^1.0.8",
     "jest-date-mock": "^1.0.8",

+ 37 - 0
apps/app/src/components/Common/Dropdown/PageItemControl.spec.tsx

@@ -0,0 +1,37 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
+
+import { PageItemControl } from './PageItemControl';
+
+
+describe('PageItemControl.tsx', () => {
+  it('Should trigger onClickRenameMenuItem() when clicking the rename button with pageInfo.isDeletable being "false"', async() => {
+    // setup
+    const onClickRenameMenuItemMock = vi.fn();
+
+    const pageInfo = {
+      isMovable: true,
+      isV5Compatible: true,
+      isEmpty: false,
+      isDeletable: false,
+      isAbleToDeleteCompletely: true,
+      isRevertible: true,
+    };
+
+    const props = {
+      pageId: 'dummy-page-id',
+      isEnableActions: true,
+      pageInfo,
+      onClickRenameMenuItem: onClickRenameMenuItemMock,
+    };
+
+    render(<PageItemControl {...props} />);
+
+    // when
+    const openPageMoveRenameModalButton = screen.getByTestId('open-page-move-rename-modal-btn');
+    await waitFor(() => userEvent.click(openPageMoveRenameModalButton, { pointerEventsCheck: PointerEventsCheckLevel.Never }));
+
+    // then
+    expect(onClickRenameMenuItemMock).toHaveBeenCalled();
+  });
+});

+ 3 - 5
apps/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -85,8 +85,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     if (onClickRenameMenuItem == null) {
     if (onClickRenameMenuItem == null) {
       return;
       return;
     }
     }
-
-    if (!pageInfo?.isDeletable) {
+    if (!pageInfo?.isMovable) {
       logger.warn('This page could not be renamed.');
       logger.warn('This page could not be renamed.');
       return;
       return;
     }
     }
@@ -177,10 +176,9 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
         ) }
 
 
         {/* Move/Rename */}
         {/* Move/Rename */}
-        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && (
+        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
           <DropdownItem
           <DropdownItem
             onClick={renameItemClickedHandler}
             onClick={renameItemClickedHandler}
-            disabled={!pageInfo.isDeletable}
             data-testid="open-page-move-rename-modal-btn"
             data-testid="open-page-move-rename-modal-btn"
             className="grw-page-control-dropdown-item"
             className="grw-page-control-dropdown-item"
           >
           >
@@ -232,7 +230,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
 
         {/* divider */}
         {/* divider */}
         {/* Delete */}
         {/* Delete */}
-        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && (
+        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && pageInfo.isDeletable && (
           <>
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem
             <DropdownItem

+ 7 - 3
apps/app/src/components/PageDeleteModal.tsx

@@ -2,7 +2,6 @@ import React, {
   useState, FC, useMemo, useEffect,
   useState, FC, useMemo, useEffect,
 } from 'react';
 } from 'react';
 
 
-import { isIPageInfoForEntity } from '@growi/core';
 import type {
 import type {
   HasObjectId,
   HasObjectId,
   IPageInfoForEntity, IPageToDeleteWithMeta, IDataWithMeta,
   IPageInfoForEntity, IPageToDeleteWithMeta, IDataWithMeta,
@@ -42,6 +41,11 @@ const deleteIconAndKey = {
   },
   },
 };
 };
 
 
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const isIPageInfoForEntityForDeleteModal = (pageInfo: any | undefined): pageInfo is IPageInfoForEntity => {
+  return pageInfo != null && 'isDeletable' in pageInfo && 'isAbleToDeleteCompletely' in pageInfo;
+};
+
 const PageDeleteModal: FC = () => {
 const PageDeleteModal: FC = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -50,14 +54,14 @@ const PageDeleteModal: FC = () => {
   const isOpened = deleteModalData?.isOpened ?? false;
   const isOpened = deleteModalData?.isOpened ?? false;
 
 
   const notOperatablePages: IPageToDeleteWithMeta[] = (deleteModalData?.pages ?? [])
   const notOperatablePages: IPageToDeleteWithMeta[] = (deleteModalData?.pages ?? [])
-    .filter(p => !isIPageInfoForEntity(p.meta));
+    .filter(p => !isIPageInfoForEntityForDeleteModal(p.meta));
   const notOperatablePageIds = notOperatablePages.map(p => p.data._id);
   const notOperatablePageIds = notOperatablePages.map(p => p.data._id);
 
 
   const { injectTo } = useSWRxPageInfoForList(notOperatablePageIds);
   const { injectTo } = useSWRxPageInfoForList(notOperatablePageIds);
 
 
   // inject IPageInfo to operate
   // inject IPageInfo to operate
   let injectedPages: IDataWithMeta<HasObjectId & { path: string }, IPageInfoForEntity>[] | null = null;
   let injectedPages: IDataWithMeta<HasObjectId & { path: string }, IPageInfoForEntity>[] | null = null;
-  if (deleteModalData?.pages != null && notOperatablePageIds.length > 0) {
+  if (deleteModalData?.pages != null) {
     injectedPages = injectTo(deleteModalData?.pages);
     injectedPages = injectTo(deleteModalData?.pages);
   }
   }
 
 

+ 21 - 3
apps/app/src/pages/[[...path]].page.tsx

@@ -1,5 +1,5 @@
-import React, { ReactNode, useEffect } from 'react';
-
+import type { ReactNode } from 'react';
+import React, { useEffect } from 'react';
 
 
 import EventEmitter from 'events';
 import EventEmitter from 'events';
 
 
@@ -23,6 +23,7 @@ import superjson from 'superjson';
 import { useEditorModeClassName } from '~/client/services/layout';
 import { useEditorModeClassName } from '~/client/services/layout';
 import { PageView } from '~/components/Page/PageView';
 import { PageView } from '~/components/Page/PageView';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
+import { SupportedAction, type SupportedActionType } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { EditorConfig } from '~/interfaces/editor-settings';
 import type { EditorConfig } from '~/interfaces/editor-settings';
 import type { IPageGrantData } from '~/interfaces/page';
 import type { IPageGrantData } from '~/interfaces/page';
@@ -58,7 +59,7 @@ import { DisplaySwitcher } from '../components/Page/DisplaySwitcher';
 import type { NextPageWithLayout } from './_app.page';
 import type { NextPageWithLayout } from './_app.page';
 import type { CommonProps } from './utils/commons';
 import type { CommonProps } from './utils/commons';
 import {
 import {
-  getNextI18NextConfig, getServerSideCommonProps, generateCustomTitleForPage, useInitSidebarConfig, skipSSR,
+  getNextI18NextConfig, getServerSideCommonProps, generateCustomTitleForPage, useInitSidebarConfig, skipSSR, addActivity,
 } from './utils/commons';
 } from './utils/commons';
 
 
 
 
@@ -596,6 +597,22 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
   props._nextI18Next = nextI18NextConfig._nextI18Next;
   props._nextI18Next = nextI18NextConfig._nextI18Next;
 }
 }
 
 
+const getAction = (props: Props): SupportedActionType => {
+  if (props.isNotCreatable) {
+    return SupportedAction.ACTION_PAGE_NOT_CREATABLE;
+  }
+  if (props.isForbidden) {
+    return SupportedAction.ACTION_PAGE_FORBIDDEN;
+  }
+  if (props.isNotFound) {
+    return SupportedAction.ACTION_PAGE_NOT_FOUND;
+  }
+  if (pagePathUtils.isUsersHomepage(props.pageWithMeta?.data.path ?? '')) {
+    return SupportedAction.ACTION_PAGE_USER_HOME_VIEW;
+  }
+  return SupportedAction.ACTION_PAGE_VIEW;
+};
+
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const req = context.req as CrowiRequest;
   const req = context.req as CrowiRequest;
   const { user } = req;
   const { user } = req;
@@ -639,6 +656,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   injectServerConfigurations(context, props);
   injectServerConfigurations(context, props);
   await injectNextI18NextConfigurations(context, props, ['translation']);
   await injectNextI18NextConfigurations(context, props, ['translation']);
 
 
+  addActivity(context, getAction(props));
   return {
   return {
     props,
     props,
   };
   };

+ 6 - 1
apps/app/src/pages/admin/audit-log.page.tsx

@@ -8,7 +8,9 @@ import Head from 'next/head';
 import { SupportedActionType } from '~/interfaces/activity';
 import { SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
-import { useCurrentUser, useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores/context';
+import {
+  useCurrentUser, useAuditLogEnabled, useAuditLogAvailableActions, useActivityExpirationSeconds,
+} from '~/stores/context';
 
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
 
@@ -19,6 +21,7 @@ const ForbiddenPage = dynamic(() => import('~/components/Admin/ForbiddenPage').t
 
 
 type Props = CommonProps & {
 type Props = CommonProps & {
   auditLogEnabled: boolean,
   auditLogEnabled: boolean,
+  activityExpirationSeconds: number,
   auditLogAvailableActions: SupportedActionType[],
   auditLogAvailableActions: SupportedActionType[],
 };
 };
 
 
@@ -26,6 +29,7 @@ type Props = CommonProps & {
 const AdminAuditLogPage: NextPage<Props> = (props) => {
 const AdminAuditLogPage: NextPage<Props> = (props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
   useAuditLogEnabled(props.auditLogEnabled);
   useAuditLogEnabled(props.auditLogEnabled);
+  useActivityExpirationSeconds(props.activityExpirationSeconds);
   useAuditLogAvailableActions(props.auditLogAvailableActions);
   useAuditLogAvailableActions(props.auditLogAvailableActions);
   useCurrentUser(props.currentUser ?? null);
   useCurrentUser(props.currentUser ?? null);
 
 
@@ -53,6 +57,7 @@ const injectServerConfigurations = async(context: GetServerSidePropsContext, pro
   const { activityService } = crowi;
   const { activityService } = crowi;
 
 
   props.auditLogEnabled = crowi.configManager.getConfig('crowi', 'app:auditLogEnabled');
   props.auditLogEnabled = crowi.configManager.getConfig('crowi', 'app:auditLogEnabled');
+  props.activityExpirationSeconds = crowi.configManager.getConfig('crowi', 'app:activityExpirationSeconds');
   props.auditLogAvailableActions = activityService.getAvailableActions(false);
   props.auditLogAvailableActions = activityService.getAvailableActions(false);
 };
 };
 
 

+ 4 - 19
apps/app/src/pages/share/[[...path]].page.tsx

@@ -12,7 +12,8 @@ import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
 import GrowiContextualSubNavigationSubstance from '~/components/Navbar/GrowiContextualSubNavigation';
 import GrowiContextualSubNavigationSubstance from '~/components/Navbar/GrowiContextualSubNavigation';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { ShareLinkPageView } from '~/components/ShareLinkPageView';
 import { ShareLinkPageView } from '~/components/ShareLinkPageView';
-import { SupportedAction, SupportedActionType } from '~/interfaces/activity';
+import type { SupportedActionType } from '~/interfaces/activity';
+import { SupportedAction } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IShareLinkHasId } from '~/interfaces/share-link';
 import type { IShareLinkHasId } from '~/interfaces/share-link';
@@ -26,8 +27,9 @@ import { useCurrentPageId, useIsNotFound, useSWRMUTxCurrentPage } from '~/stores
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type { NextPageWithLayout } from '../_app.page';
 import type { NextPageWithLayout } from '../_app.page';
+import type { CommonProps } from '../utils/commons';
 import {
 import {
-  getServerSideCommonProps, generateCustomTitleForPage, getNextI18NextConfig, CommonProps, skipSSR,
+  getServerSideCommonProps, generateCustomTitleForPage, getNextI18NextConfig, skipSSR, addActivity,
 } from '../utils/commons';
 } from '../utils/commons';
 
 
 const logger = loggerFactory('growi:next-page:share');
 const logger = loggerFactory('growi:next-page:share');
@@ -201,23 +203,6 @@ function getAction(props: Props): SupportedActionType {
 
 
   return action;
   return action;
 }
 }
-
-async function addActivity(context: GetServerSidePropsContext, action: SupportedActionType): Promise<void> {
-  const req: CrowiRequest = context.req as CrowiRequest;
-
-  const parameters = {
-    ip: req.ip,
-    endpoint: req.originalUrl,
-    action,
-    user: req.user?._id,
-    snapshot: {
-      username: req.user?.username,
-    },
-  };
-
-  await req.crowi.activityService.createActivity(parameters);
-}
-
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const req = context.req as CrowiRequest;
   const req = context.req as CrowiRequest;
   const { crowi, params } = req;
   const { crowi, params } = req;

+ 18 - 0
apps/app/src/pages/utils/commons.ts

@@ -5,9 +5,11 @@ import { isServer } from '@growi/core/dist/utils';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import type { SSRConfig, UserConfig } from 'next-i18next';
 import type { SSRConfig, UserConfig } from 'next-i18next';
 
 
+
 import * as nextI18NextConfig from '^/config/next-i18next.config';
 import * as nextI18NextConfig from '^/config/next-i18next.config';
 
 
 import { detectLocaleFromBrowserAcceptLanguage } from '~/client/util/locale-utils';
 import { detectLocaleFromBrowserAcceptLanguage } from '~/client/util/locale-utils';
+import { type SupportedActionType } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
@@ -183,3 +185,19 @@ export const skipSSR = async(page: PageDocument, ssrMaxRevisionBodyLength: numbe
 
 
   return ssrMaxRevisionBodyLength < latestRevisionBodyLength;
   return ssrMaxRevisionBodyLength < latestRevisionBodyLength;
 };
 };
+
+export const addActivity = async(context: GetServerSidePropsContext, action: SupportedActionType): Promise<void> => {
+  const req = context.req as CrowiRequest;
+
+  const parameters = {
+    ip: req.ip,
+    endpoint: req.originalUrl,
+    action,
+    user: req.user?._id,
+    snapshot: {
+      username: req.user?.username,
+    },
+  };
+
+  await req.crowi.activityService.createActivity(parameters);
+};

+ 3 - 0
apps/app/src/server/crowi/index.js

@@ -29,6 +29,7 @@ import { instanciate as instanciateExternalAccountService } from '../service/ext
 import { FileUploader, getUploader } from '../service/file-uploader'; // eslint-disable-line no-unused-vars
 import { FileUploader, getUploader } from '../service/file-uploader'; // eslint-disable-line no-unused-vars
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { InstallerService } from '../service/installer';
 import { InstallerService } from '../service/installer';
+import { normalizeData } from '../service/normalize-data';
 import PageService from '../service/page';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
 import PageOperationService from '../service/page-operation';
@@ -172,6 +173,8 @@ Crowi.prototype.init = async function() {
   ]);
   ]);
 
 
   await this.autoInstall();
   await this.autoInstall();
+
+  await normalizeData();
 };
 };
 
 
 /**
 /**

+ 25 - 10
apps/app/src/server/service/growi-bridge.js → apps/app/src/server/service/growi-bridge/index.ts

@@ -1,9 +1,14 @@
+import { Model } from 'mongoose';
+import unzipStream, { type Entry } from 'unzip-stream';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { tapStreamDataByPromise } from './unzip-stream-utils';
+
 const fs = require('fs');
 const fs = require('fs');
 const path = require('path');
 const path = require('path');
+
 const streamToPromise = require('stream-to-promise');
 const streamToPromise = require('stream-to-promise');
-const unzipper = require('unzipper');
 
 
 const logger = loggerFactory('growi:services:GrowiBridgeService'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:services:GrowiBridgeService'); // eslint-disable-line no-unused-vars
 
 
@@ -13,6 +18,14 @@ const logger = loggerFactory('growi:services:GrowiBridgeService'); // eslint-dis
  */
  */
 class GrowiBridgeService {
 class GrowiBridgeService {
 
 
+  crowi: any;
+
+  encoding: string;
+
+  metaFileName: string;
+
+  baseDir: null;
+
   constructor(crowi) {
   constructor(crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
     this.encoding = 'utf-8';
     this.encoding = 'utf-8';
@@ -47,7 +60,7 @@ class GrowiBridgeService {
    * @return {object} instance of mongoose model
    * @return {object} instance of mongoose model
    */
    */
   getModelFromCollectionName(collectionName) {
   getModelFromCollectionName(collectionName) {
-    const Model = Object.values(this.crowi.models).find((m) => {
+    const Model = Object.values(this.crowi.models).find((m: Model<unknown>) => {
       return m.collection != null && m.collection.name === collectionName;
       return m.collection != null && m.collection.name === collectionName;
     });
     });
 
 
@@ -84,18 +97,20 @@ class GrowiBridgeService {
    */
    */
   async parseZipFile(zipFile) {
   async parseZipFile(zipFile) {
     const fileStat = fs.statSync(zipFile);
     const fileStat = fs.statSync(zipFile);
-    const innerFileStats = [];
+    const innerFileStats: Array<{ fileName: string, collectionName: string, size: number }> = [];
     let meta = {};
     let meta = {};
 
 
     const readStream = fs.createReadStream(zipFile);
     const readStream = fs.createReadStream(zipFile);
-    const unzipStream = readStream.pipe(unzipper.Parse());
+    const unzipStreamPipe = readStream.pipe(unzipStream.Parse());
+    let tapPromise;
 
 
-    unzipStream.on('entry', async(entry) => {
+    const unzipEntryStream = unzipStreamPipe.on('entry', (entry: Entry) => {
       const fileName = entry.path;
       const fileName = entry.path;
-      const size = entry.vars.uncompressedSize; // There is also compressedSize;
-
+      const size = entry.size; // might be undefined in some archives
       if (fileName === this.getMetaFileName()) {
       if (fileName === this.getMetaFileName()) {
-        meta = JSON.parse((await entry.buffer()).toString());
+        tapPromise = tapStreamDataByPromise(entry).then((metaBuffer) => {
+          meta = JSON.parse(metaBuffer.toString());
+        });
       }
       }
       else {
       else {
         innerFileStats.push({
         innerFileStats.push({
@@ -104,12 +119,12 @@ class GrowiBridgeService {
           size,
           size,
         });
         });
       }
       }
-
       entry.autodrain();
       entry.autodrain();
     });
     });
 
 
     try {
     try {
-      await streamToPromise(unzipStream);
+      await streamToPromise(unzipEntryStream);
+      await tapPromise;
     }
     }
     // if zip is broken
     // if zip is broken
     catch (err) {
     catch (err) {

+ 22 - 0
apps/app/src/server/service/growi-bridge/unzip-stream-utils.ts

@@ -0,0 +1,22 @@
+import { PassThrough } from 'stream';
+
+import type { Entry } from 'unzip-stream';
+
+export const tapStreamDataByPromise = (entry: Entry): Promise<Buffer> => {
+  return new Promise((resolve, reject) => {
+    const buffers: Array<Buffer> = [];
+
+    const entryContentGetterStream = new PassThrough()
+      .on('data', (chunk) => {
+        buffers.push(Buffer.from(chunk));
+      })
+      .on('end', () => {
+        resolve(Buffer.concat(buffers));
+      })
+      .on('error', reject);
+
+    entry
+      .pipe(entryContentGetterStream)
+      .on('error', reject);
+  });
+};

+ 9 - 4
apps/app/src/server/service/import.js

@@ -1,3 +1,8 @@
+/**
+ * @typedef {import("@types/unzip-stream").Parse} Parse
+ * @typedef {import("@types/unzip-stream").Entry} Entry
+ */
+
 import gc from 'expose-gc/function';
 import gc from 'expose-gc/function';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -11,7 +16,7 @@ const parseISO = require('date-fns/parseISO');
 const isIsoDate = require('is-iso-date');
 const isIsoDate = require('is-iso-date');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const streamToPromise = require('stream-to-promise');
 const streamToPromise = require('stream-to-promise');
-const unzipper = require('unzipper');
+const unzipStream = require('unzip-stream');
 
 
 const CollectionProgressingStatus = require('../models/vo/collection-progressing-status');
 const CollectionProgressingStatus = require('../models/vo/collection-progressing-status');
 const { createBatchStream } = require('../util/batch-stream');
 const { createBatchStream } = require('../util/batch-stream');
@@ -386,10 +391,10 @@ class ImportService {
    */
    */
   async unzip(zipFile) {
   async unzip(zipFile) {
     const readStream = fs.createReadStream(zipFile);
     const readStream = fs.createReadStream(zipFile);
-    const unzipStream = readStream.pipe(unzipper.Parse());
+    const unzipStreamPipe = readStream.pipe(unzipStream.Parse());
     const files = [];
     const files = [];
 
 
-    unzipStream.on('entry', (entry) => {
+    unzipStreamPipe.on('entry', (/** @type {Entry} */ entry) => {
       const fileName = entry.path;
       const fileName = entry.path;
       // https://regex101.com/r/mD4eZs/6
       // https://regex101.com/r/mD4eZs/6
       // prevent from unexpecting attack doing unzip file (path traversal attack)
       // prevent from unexpecting attack doing unzip file (path traversal attack)
@@ -412,7 +417,7 @@ class ImportService {
       }
       }
     });
     });
 
 
-    await streamToPromise(unzipStream);
+    await streamToPromise(unzipStreamPipe);
 
 
     return files;
     return files;
   }
   }

+ 12 - 0
apps/app/src/server/service/normalize-data/index.ts

@@ -0,0 +1,12 @@
+import loggerFactory from '~/utils/logger';
+
+import { renameDuplicateRootPages } from './rename-duplicate-root-pages';
+
+const logger = loggerFactory('growi:service:NormalizeData');
+
+export const normalizeData = async(): Promise<void> => {
+  await renameDuplicateRootPages();
+
+  logger.info('normalizeData has been executed');
+  return;
+};

+ 31 - 0
apps/app/src/server/service/normalize-data/rename-duplicate-root-pages.ts

@@ -0,0 +1,31 @@
+// see: https://github.com/weseek/growi/issues/8337
+
+import { type IPageHasId } from '@growi/core';
+import mongoose from 'mongoose';
+
+import { type PageModel } from '~/server/models/page';
+
+export const renameDuplicateRootPages = async(): Promise<void> => {
+  const Page = mongoose.model<IPageHasId, PageModel>('Page');
+  const rootPages = await Page.find({ path: '/' }).sort({ createdAt: 1 });
+
+  if (rootPages.length <= 1) {
+    return;
+  }
+
+  const duplicatedRootPages = rootPages.slice(1);
+  const requests = duplicatedRootPages.map((page) => {
+    return {
+      updateOne: {
+        filter: { _id: page._id },
+        update: {
+          $set: {
+            parent: rootPages[0],
+            path: `/obsolete-root-page-${page._id.toString()}`,
+          },
+        },
+      },
+    };
+  });
+  await Page.bulkWrite(requests);
+};

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

@@ -333,6 +333,7 @@ class PageService implements IPageService {
         meta: {
         meta: {
           isV5Compatible: isTopPage(page.path) || page.parent != null,
           isV5Compatible: isTopPage(page.path) || page.parent != null,
           isEmpty: page.isEmpty,
           isEmpty: page.isEmpty,
+          isMovable: false,
           isDeletable: false,
           isDeletable: false,
           isAbleToDeleteCompletely: false,
           isAbleToDeleteCompletely: false,
           isRevertible: false,
           isRevertible: false,
@@ -2408,12 +2409,14 @@ class PageService implements IPageService {
   }
   }
 
 
   constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
   constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
+    const isMovable = isGuestUser ? false : isMovablePage(page.path);
     const isDeletable = !(isGuestUser || isTopPage(page.path) || isUsersTopPage(page.path));
     const isDeletable = !(isGuestUser || isTopPage(page.path) || isUsersTopPage(page.path));
 
 
     if (page.isEmpty) {
     if (page.isEmpty) {
       return {
       return {
         isV5Compatible: true,
         isV5Compatible: true,
         isEmpty: true,
         isEmpty: true,
+        isMovable,
         isDeletable: false,
         isDeletable: false,
         isAbleToDeleteCompletely: false,
         isAbleToDeleteCompletely: false,
         isRevertible: false,
         isRevertible: false,
@@ -2430,6 +2433,7 @@ class PageService implements IPageService {
       likerIds: this.extractStringIds(likers),
       likerIds: this.extractStringIds(likers),
       seenUserIds: this.extractStringIds(seenUsers),
       seenUserIds: this.extractStringIds(seenUsers),
       sumOfSeenUsers: page.seenUsers.length,
       sumOfSeenUsers: page.seenUsers.length,
+      isMovable,
       isDeletable,
       isDeletable,
       isAbleToDeleteCompletely: false,
       isAbleToDeleteCompletely: false,
       isRevertible: isTrashPage(page.path),
       isRevertible: isTrashPage(page.path),

+ 0 - 1
apps/app/src/stores/context.tsx

@@ -124,7 +124,6 @@ export const useAuditLogEnabled = (initialData?: boolean): SWRResponse<boolean,
   return useContextSWR<boolean, Error>('auditLogEnabled', initialData, { fallbackData: false });
   return useContextSWR<boolean, Error>('auditLogEnabled', initialData, { fallbackData: false });
 };
 };
 
 
-// TODO: initialize in [[..path]].page.tsx?
 export const useActivityExpirationSeconds = (initialData?: number) : SWRResponse<number, Error> => {
 export const useActivityExpirationSeconds = (initialData?: number) : SWRResponse<number, Error> => {
   return useContextSWR<number, Error>('activityExpirationSeconds', initialData);
   return useContextSWR<number, Error>('activityExpirationSeconds', initialData);
 };
 };

+ 16 - 0
apps/app/vitest.config.components.ts

@@ -0,0 +1,16 @@
+import react from '@vitejs/plugin-react';
+import tsconfigPaths from 'vite-tsconfig-paths';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  plugins: [
+    react(), tsconfigPaths(),
+  ],
+  test: {
+    globals: true,
+    environment: 'happy-dom',
+    include: [
+      '**/*.spec.{tsx,jsx}',
+    ],
+  },
+});

+ 1 - 1
apps/app/vitest.config.ts

@@ -8,7 +8,7 @@ export default defineConfig({
   test: {
   test: {
     environment: 'node',
     environment: 'node',
     exclude: [
     exclude: [
-      '**/test/**',
+      '**/test/**', '**/*.spec.{tsx,jsx}',
     ],
     ],
     clearMocks: true,
     clearMocks: true,
     globals: true,
     globals: true,

+ 1 - 1
package.json

@@ -89,7 +89,7 @@
     "ts-node-dev": "^2.0.0",
     "ts-node-dev": "^2.0.0",
     "tsconfig-paths": "^3.9.0",
     "tsconfig-paths": "^3.9.0",
     "typescript": "~5.0.0",
     "typescript": "~5.0.0",
-    "vite": "^4.5.1",
+    "vite": "^4.5.2",
     "vite-plugin-dts": "^2.3.0",
     "vite-plugin-dts": "^2.3.0",
     "vite-tsconfig-paths": "^4.2.0",
     "vite-tsconfig-paths": "^4.2.0",
     "vitest": "^0.34.6",
     "vitest": "^0.34.6",

+ 1 - 0
packages/core/src/interfaces/page.ts

@@ -80,6 +80,7 @@ export type IPageHasId = IPage & HasObjectId;
 export type IPageInfo = {
 export type IPageInfo = {
   isV5Compatible: boolean,
   isV5Compatible: boolean,
   isEmpty: boolean,
   isEmpty: boolean,
+  isMovable: boolean,
   isDeletable: boolean,
   isDeletable: boolean,
   isAbleToDeleteCompletely: boolean,
   isAbleToDeleteCompletely: boolean,
   isRevertible: boolean,
   isRevertible: boolean,

+ 2 - 2
packages/core/src/utils/page-path-utils/index.ts

@@ -100,7 +100,7 @@ export const isSharedPage = (path: string): boolean => {
 };
 };
 
 
 const restrictedPatternsToCreate: Array<RegExp> = [
 const restrictedPatternsToCreate: Array<RegExp> = [
-  /\^|\$|\*|\+|#|%|\?/,
+  /\^|\$|\*|\+|#|<|>|%|\?/,
   /^\/-\/.*/,
   /^\/-\/.*/,
   /^\/_r\/.*/,
   /^\/_r\/.*/,
   /^\/_apix?(\/.*)?/,
   /^\/_apix?(\/.*)?/,
@@ -114,7 +114,7 @@ const restrictedPatternsToCreate: Array<RegExp> = [
   /\\/, // see: https://github.com/weseek/growi/issues/7241
   /\\/, // see: https://github.com/weseek/growi/issues/7241
   /^\/(_search|_private-legacy-pages)(\/.*|$)/,
   /^\/(_search|_private-legacy-pages)(\/.*|$)/,
   /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags|share|attachment)(\/.*|$)/,
   /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags|share|attachment)(\/.*|$)/,
-  /^\/user\/[^/]+$/, // see: https://regex101.com/r/utVQct/1
+  /^\/user(?:\/[^/]+)?$/, // https://regex101.com/r/9Eh2S1/1
 ];
 ];
 export const isCreatablePage = (path: string): boolean => {
 export const isCreatablePage = (path: string): boolean => {
   return !restrictedPatternsToCreate.some(pattern => path.match(pattern));
   return !restrictedPatternsToCreate.some(pattern => path.match(pattern));

Разлика између датотеке није приказан због своје велике величине
+ 247 - 345
yarn.lock


Неке датотеке нису приказане због велике количине промена