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

Merge pull request #6495 from weseek/master

Release v5.1.4
Yuki Takei 3 лет назад
Родитель
Сommit
12cf685ce8
43 измененных файлов с 637 добавлено и 270 удалено
  1. 6 5
      .github/workflows/release-slackbot-proxy.yml
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 2 2
      packages/app/docker/README.md
  5. 8 8
      packages/app/package.json
  6. 2 1
      packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  7. 17 18
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  8. 11 2
      packages/app/src/components/Common/ClosableTextInput.tsx
  9. 16 0
      packages/app/src/components/InAppNotification/InAppNotificationElm.tsx
  10. 1 1
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  11. 6 5
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  12. 6 1
      packages/app/src/components/PageStatusAlert.jsx
  13. 31 14
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  14. 1 1
      packages/app/src/components/Sidebar/RecentChanges.tsx
  15. 16 0
      packages/app/src/interfaces/activity.ts
  16. 15 0
      packages/app/src/interfaces/subscription.ts
  17. 4 12
      packages/app/src/server/models/activity.ts
  18. 19 21
      packages/app/src/server/models/subscription.ts
  19. 5 0
      packages/app/src/server/routes/apiv3/page.js
  20. 8 12
      packages/app/src/server/routes/apiv3/pages.js
  21. 2 2
      packages/app/src/server/routes/index.js
  22. 14 17
      packages/app/src/server/routes/page.js
  23. 12 6
      packages/app/src/server/service/activity.ts
  24. 21 14
      packages/app/src/server/service/global-notification/global-notification-mail.js
  25. 1 1
      packages/app/src/server/service/global-notification/index.js
  26. 22 11
      packages/app/src/server/service/in-app-notification.ts
  27. 126 29
      packages/app/src/server/service/page.ts
  28. 1 0
      packages/app/src/services/renderer/growi-renderer.ts
  29. 9 0
      packages/app/src/styles/_recent-changes.scss
  30. 1 1
      packages/app/test/cypress/integration/10-install/install.spec.ts
  31. 1 1
      packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts
  32. 38 13
      packages/app/test/integration/service/page.test.js
  33. 63 21
      packages/app/test/integration/service/v5.non-public-page.test.ts
  34. 14 4
      packages/app/test/integration/service/v5.page.test.ts
  35. 127 36
      packages/app/test/integration/service/v5.public-page.test.ts
  36. 1 1
      packages/codemirror-textlint/package.json
  37. 1 1
      packages/core/package.json
  38. 1 1
      packages/plugin-attachment-refs/package.json
  39. 1 1
      packages/plugin-lsx/package.json
  40. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  41. 1 1
      packages/slack/package.json
  42. 2 2
      packages/slackbot-proxy/package.json
  43. 1 1
      packages/ui/package.json

+ 6 - 5
.github/workflows/release-slackbot-proxy.yml

@@ -42,12 +42,13 @@ jobs:
         username: wsmoogle
         password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
 
-    - name: Setup gcloud
-      uses: google-github-actions/setup-gcloud@master
+    - name: Authenticate to Google Cloud for GROWI.cloud
+      uses: google-github-actions/auth@v0
       with:
-        project_id: ${{ secrets.GCP_PRJ_ID_SLACKBOT_PROXY }}
-        service_account_key: ${{ secrets.GCP_SA_KEY_SLACKBOT_PROXY }}
-        export_default_credentials: true
+        credentials_json: '${{ secrets.GCP_SA_KEY_SLACKBOT_PROXY }}'
+
+    - name: Setup gcloud
+      uses: google-github-actions/setup-gcloud@v0
 
     - name: Configure docker for gcloud
       run: |

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "5.1.3",
+  "version": "5.1.4-RC.0",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.1.3",
+  "version": "5.1.4-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 2 - 2
packages/app/docker/README.md

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`5.1.3`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.3/docker/Dockerfile)
-* [`5.1.3-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.3/docker/Dockerfile)
+* [`5.1.3`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.3/packages/app/docker/Dockerfile)
+* [`5.1.3-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.3/packages/app/docker/Dockerfile)
 * [`5.0.11`, `5.0` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
 * [`5.0.11-nocdn`, `5.0-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
 * [`4.5.23`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)

+ 8 - 8
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.1.3",
+  "version": "5.1.4-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -64,12 +64,12 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.1.3",
-    "@growi/core": "^5.1.3",
-    "@growi/plugin-attachment-refs": "^5.1.3",
-    "@growi/plugin-lsx": "^5.1.3",
-    "@growi/plugin-pukiwiki-like-linker": "^5.1.3",
-    "@growi/slack": "^5.1.3",
+    "@growi/codemirror-textlint": "^5.1.4-RC.0",
+    "@growi/core": "^5.1.4-RC.0",
+    "@growi/plugin-attachment-refs": "^5.1.4-RC.0",
+    "@growi/plugin-lsx": "^5.1.4-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.1.4-RC.0",
+    "@growi/slack": "^5.1.4-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -172,7 +172,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.1.3",
+    "@growi/ui": "^5.1.4-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

+ 2 - 1
packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx

@@ -1,4 +1,5 @@
 import React, { FC, useState } from 'react';
+
 import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -29,7 +30,7 @@ const UpdateParentConfirmModal: FC = () => {
         <i className="icon icon-warning"></i> {t('admin:user_group_management.update_parent_confirm_modal.header')}
       </ModalHeader>
       {
-        targetGroup != null && updateData != null && updateData?.parent !== undefined ? (
+        targetGroup != null && updateData != null ? (
           <>
             <ModalBody>
               <div className="mb-2">

+ 17 - 18
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -1,32 +1,35 @@
 import React, {
   FC, useState, useCallback,
 } from 'react';
+
 import { useTranslation } from 'react-i18next';
 
-import UserGroupForm from '../UserGroup/UserGroupForm';
-import UserGroupTable from '../UserGroup/UserGroupTable';
-import UserGroupModal from '../UserGroup/UserGroupModal';
-import UserGroupDeleteModal from '../UserGroup/UserGroupDeleteModal';
-import UpdateParentConfirmModal from './UpdateParentConfirmModal';
-import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
-import UserGroupUserTable from './UserGroupUserTable';
-import UserGroupUserModal from './UserGroupUserModal';
-import UserGroupPageList from './UserGroupPageList';
 
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { IPageHasId } from '~/interfaces/page';
 import {
   IUserGroup, IUserGroupHasId,
 } from '~/interfaces/user';
+import { useIsAclEnabled } from '~/stores/context';
+import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 import {
   useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList,
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups,
 } from '~/stores/user-group';
-import { useIsAclEnabled } from '~/stores/context';
-import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
+
+import UserGroupDeleteModal from '../UserGroup/UserGroupDeleteModal';
+import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
+import UserGroupForm from '../UserGroup/UserGroupForm';
+import UserGroupModal from '../UserGroup/UserGroupModal';
+import UserGroupTable from '../UserGroup/UserGroupTable';
+
+import UpdateParentConfirmModal from './UpdateParentConfirmModal';
+import UserGroupPageList from './UserGroupPageList';
+import UserGroupUserModal from './UserGroupUserModal';
+import UserGroupUserTable from './UserGroupUserTable';
 
 const UserGroupDetailPage: FC = () => {
   const { t } = useTranslation();
@@ -85,15 +88,11 @@ const UserGroupDetailPage: FC = () => {
   }, []);
 
   const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
-    if (update.parent == null) {
-      throw Error('"parent" attr must not be null');
-    }
-
     const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
     const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
       name: update.name,
       description: update.description,
-      parentId,
+      parentId: parentId ?? null,
       forceUpdateParents,
     });
     const { userGroup: updatedUserGroup } = res.data;
@@ -120,7 +119,7 @@ const UserGroupDetailPage: FC = () => {
   );
 
   const onClickSubmitForm = useCallback(async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>): Promise<void> => {
-    if (userGroupData?.parent === undefined || typeof userGroupData?.parent === 'string') {
+    if (typeof userGroupData?.parent === 'string') {
       toastError(t('Something went wrong. Please try again.'));
       return;
     }

+ 11 - 2
packages/app/src/components/Common/ClosableTextInput.tsx

@@ -1,6 +1,7 @@
 import React, {
   FC, memo, useEffect, useRef, useState,
 } from 'react';
+
 import { useTranslation } from 'react-i18next';
 
 export const AlertType = {
@@ -29,7 +30,8 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
 
   const [inputText, setInputText] = useState(props.value);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
-  const [isAbleToShowAlert, setIsAbleToShowAlert] = useState<boolean>(false);
+  const [isAbleToShowAlert, setIsAbleToShowAlert] = useState(false);
+  const [isComposing, setComposing] = useState(false);
 
   const createValidation = async(inputText: string) => {
     if (props.inputValidator != null) {
@@ -62,6 +64,10 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
   const onKeyDownHandler = (e) => {
     switch (e.key) {
       case 'Enter':
+        // Do nothing when composing
+        if (isComposing) {
+          return;
+        }
         onPressEnter();
         break;
       default:
@@ -106,7 +112,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
 
 
   return (
-    <div className="d-block flex-fill">
+    <div>
       <input
         value={inputText || ''}
         ref={inputRef}
@@ -114,9 +120,12 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
         className="form-control"
         placeholder={props.placeholder}
         name="input"
+        data-testid="closable-text-input"
         onFocus={onFocusHandler}
         onChange={onChangeHandler}
         onKeyDown={onKeyDownHandler}
+        onCompositionStart={() => setComposing(true)}
+        onCompositionEnd={() => setComposing(false)}
         onBlur={onBlurHandler}
         autoFocus={false}
       />

+ 16 - 0
packages/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -119,6 +119,22 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       actionMsg = 'reverted';
       actionIcon = 'icon-action-undo';
       break;
+    case 'PAGE_RECURSIVELY_RENAME':
+      actionMsg = 'renamed under';
+      actionIcon = 'icon-action-redo';
+      break;
+    case 'PAGE_RECURSIVELY_DELETE':
+      actionMsg = 'deleted under';
+      actionIcon = 'icon-trash';
+      break;
+    case 'PAGE_RECURSIVELY_DELETE_COMPLETELY':
+      actionMsg = 'deleted completely under';
+      actionIcon = 'icon-fire';
+      break;
+    case 'PAGE_RECURSIVELY_REVERT':
+      actionMsg = 'reverted under';
+      actionIcon = 'icon-action-undo';
+      break;
     case 'COMMENT_CREATE':
       actionMsg = 'commented on';
       actionIcon = 'icon-bubble';

+ 1 - 1
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -217,7 +217,7 @@ const GrowiContextualSubNavigation = (props) => {
       await pageContainer.setState({ revisionId: updatedRevisionId });
 
       const lastUpdateUser = res.savedPage?.lastUpdateUser as IUser;
-      await pageContainer.setState({ lastUpdateUsername: lastUpdateUser.username });
+      await pageContainer.setState({ lastUpdateUser });
 
       // revalidate SWRTagsInfo
       mutateSWRTagsInfo();

+ 6 - 5
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -25,7 +25,8 @@ import geu from './GridEditorUtil';
 import HandsontableModal from './HandsontableModal';
 import LinkEditModal from './LinkEditModal';
 import mdu from './MarkdownDrawioUtil';
-import mlu from './MarkdownLinkUtil';
+import markdownLinkUtil from './MarkdownLinkUtil';
+import markdownListUtil from './MarkdownListUtil';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
 import mtu from './MarkdownTableUtil';
 import pasteHelper from './PasteHelper';
@@ -521,7 +522,7 @@ class CodeMirrorEditor extends AbstractEditor {
     interceptorManager.process('preHandleEnter', context)
       .then(() => {
         if (context.handlers.length === 0) {
-          codemirror.commands.newlineAndIndentContinueMarkdownList(this.getCodeMirror());
+          markdownListUtil.newlineAndIndentContinueMarkdownList(this);
         }
       });
   }
@@ -548,7 +549,7 @@ class CodeMirrorEditor extends AbstractEditor {
     const hasLinkClass = additionalClassSet.has(MARKDOWN_LINK_ACTIVATED_CLASS);
 
     const isInTable = mtu.isInTable(editor);
-    const isInLink = mlu.isInLink(editor);
+    const isInLink = markdownLinkUtil.isInLink(editor);
 
     if (!hasCustomClass && isInTable) {
       additionalClassSet.add(MARKDOWN_TABLE_ACTIVATED_CLASS);
@@ -794,7 +795,7 @@ class CodeMirrorEditor extends AbstractEditor {
   }
 
   showLinkEditHandler() {
-    this.linkEditModal.current.show(mlu.getMarkdownLink(this.getCodeMirror()));
+    this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
   }
 
   showHandsonTableHandler() {
@@ -1058,7 +1059,7 @@ class CodeMirrorEditor extends AbstractEditor {
         />
         <LinkEditModal
           ref={this.linkEditModal}
-          onSave={(linkText) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
+          onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
         <HandsontableModal
           ref={this.handsontableModal}

+ 6 - 1
packages/app/src/components/PageStatusAlert.jsx

@@ -1,10 +1,12 @@
 import React from 'react';
 
 import PropTypes from 'prop-types';
+import ReactDOMServer from 'react-dom/server';
 import { useTranslation } from 'react-i18next';
 
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
+import Username from '~/components/User/Username';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
@@ -82,9 +84,12 @@ class PageStatusAlert extends React.Component {
       isConflictOnEdit = markdownOnEdit !== pageContainer.state.markdown;
     }
 
+    const usernameComponentToString = ReactDOMServer.renderToString(<Username user={pageContainer.state.lastUpdateUser} />);
+
     const label1 = isConflictOnEdit
       ? t('modal_resolve_conflict.file_conflicting_with_newer_remote')
-      : `${pageContainer.state.lastUpdateUsername} ${t('edited this page')}`;
+      // eslint-disable-next-line react/no-danger
+      : <span dangerouslySetInnerHTML={{ __html: `${usernameComponentToString} ${t('edited this page')}` }} />;
 
     return [
       ['bg-warning'],

+ 31 - 14
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useCallback, useState, FC, useEffect,
+  useCallback, useState, FC, useEffect, ReactNode,
 } from 'react';
 
 import nodePath from 'path';
@@ -93,6 +93,15 @@ const isDroppable = (fromPage?: Partial<IPageHasId>, newParentPage?: Partial<IPa
   return pagePathUtils.canMoveByPath(fromPage.path, newPathAfterMoved) && !pagePathUtils.isUsersTopPage(newParentPage.path);
 };
 
+// Component wrapper to make a child element not draggable
+// https://github.com/react-dnd/react-dnd/issues/335
+type NotDraggableProps = {
+  children: ReactNode,
+};
+const NotDraggableForClosableTextInput = (props: NotDraggableProps): JSX.Element => {
+  return <div draggable onDragStart={e => e.preventDefault()}>{props.children}</div>;
+};
+
 
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
@@ -439,13 +448,17 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
         </div>
         { isRenameInputShown
           ? (
-            <ClosableTextInput
-              value={nodePath.basename(page.path ?? '')}
-              placeholder={t('Input page name')}
-              onClickOutside={() => { setRenameInputShown(false) }}
-              onPressEnter={onPressEnterForRenameHandler}
-              inputValidator={inputValidator}
-            />
+            <div className="flex-fill">
+              <NotDraggableForClosableTextInput>
+                <ClosableTextInput
+                  value={nodePath.basename(page.path ?? '')}
+                  placeholder={t('Input page name')}
+                  onClickOutside={() => { setRenameInputShown(false) }}
+                  onPressEnter={onPressEnterForRenameHandler}
+                  inputValidator={inputValidator}
+                />
+              </NotDraggableForClosableTextInput>
+            </div>
           )
           : (
             <>
@@ -499,12 +512,16 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       </li>
 
       {isEnableActions && isNewPageInputShown && (
-        <ClosableTextInput
-          placeholder={t('Input page name')}
-          onClickOutside={() => { setNewPageInputShown(false) }}
-          onPressEnter={onPressEnterForCreateHandler}
-          inputValidator={inputValidator}
-        />
+        <div className="flex-fill">
+          <NotDraggableForClosableTextInput>
+            <ClosableTextInput
+              placeholder={t('Input page name')}
+              onClickOutside={() => { setNewPageInputShown(false) }}
+              onPressEnter={onPressEnterForCreateHandler}
+              inputValidator={inputValidator}
+            />
+          </NotDraggableForClosableTextInput>
+        </div>
       )}
       {
         isOpen && hasChildren() && currentChildren.map((node, index) => (

+ 1 - 1
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -107,7 +107,7 @@ function SmallPageItem({ page }) {
         <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
         <div className="flex-grow-1 ml-2">
           { !dPagePath.isRoot && <FormerLink /> }
-          <h5 className="my-0">
+          <h5 className="my-0 text-truncate">
             <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
             {locked}
           </h5>

+ 16 - 0
packages/app/src/interfaces/activity.ts

@@ -46,6 +46,10 @@ const ACTION_PAGE_DELETE = 'PAGE_DELETE';
 const ACTION_PAGE_DELETE_COMPLETELY = 'PAGE_DELETE_COMPLETELY';
 const ACTION_PAGE_REVERT = 'PAGE_REVERT';
 const ACTION_PAGE_EMPTY_TRASH = 'PAGE_EMPTY_TRASH';
+const ACTION_PAGE_RECURSIVELY_RENAME = 'PAGE_RECURSIVELY_RENAME';
+const ACTION_PAGE_RECURSIVELY_DELETE = 'PAGE_RECURSIVELY_DELETE';
+const ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY = 'PAGE_RECURSIVELY_DELETE_COMPLETELY';
+const ACTION_PAGE_RECURSIVELY_REVERT = 'PAGE_RECURSIVELY_REVERT';
 const ACTION_PAGE_SUBSCRIBE = 'PAGE_SUBSCRIBE';
 const ACTION_PAGE_UNSUBSCRIBE = 'PAGE_UNSUBSCRIBE';
 const ACTION_PAGE_EXPORT = 'PAGE_EXPORT';
@@ -225,6 +229,10 @@ export const SupportedAction = {
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_REVERT,
   ACTION_PAGE_EMPTY_TRASH,
+  ACTION_PAGE_RECURSIVELY_RENAME,
+  ACTION_PAGE_RECURSIVELY_DELETE,
+  ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY,
+  ACTION_PAGE_RECURSIVELY_REVERT,
   ACTION_PAGE_SUBSCRIBE,
   ACTION_PAGE_UNSUBSCRIBE,
   ACTION_PAGE_EXPORT,
@@ -354,6 +362,10 @@ export const EssentialActionGroup = {
   ACTION_PAGE_DELETE,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_REVERT,
+  ACTION_PAGE_RECURSIVELY_RENAME,
+  ACTION_PAGE_RECURSIVELY_DELETE,
+  ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY,
+  ACTION_PAGE_RECURSIVELY_REVERT,
   ACTION_COMMENT_CREATE,
 } as const;
 
@@ -405,6 +417,10 @@ export const MediumActionGroup = {
   ACTION_PAGE_DELETE,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_REVERT,
+  ACTION_PAGE_RECURSIVELY_RENAME,
+  ACTION_PAGE_RECURSIVELY_DELETE,
+  ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY,
+  ACTION_PAGE_RECURSIVELY_REVERT,
   ACTION_PAGE_EMPTY_TRASH,
   ACTION_PAGE_SUBSCRIBE,
   ACTION_PAGE_UNSUBSCRIBE,

+ 15 - 0
packages/app/src/interfaces/subscription.ts

@@ -1,6 +1,21 @@
+import { Ref } from './common';
+import { IPage } from './page';
+import { IUser } from './user';
+
 export const SubscriptionStatusType = {
   SUBSCRIBE: 'SUBSCRIBE',
   UNSUBSCRIBE: 'UNSUBSCRIBE',
 } as const;
 export const AllSubscriptionStatusType = Object.values(SubscriptionStatusType);
 export type SubscriptionStatusType = typeof SubscriptionStatusType[keyof typeof SubscriptionStatusType];
+
+export interface ISubscription {
+  user: Ref<IUser>
+  targetModel: string
+  target: Ref<IPage>
+  status: string
+  createdAt: Date
+
+  isSubscribing(): boolean
+  isUnsubscribing(): boolean
+}

+ 4 - 12
packages/app/src/server/models/activity.ts

@@ -9,6 +9,8 @@ import {
   AllSupportedTargetModels, SupportedTargetModelType,
   AllSupportedEventModels, SupportedEventModelType,
 } from '~/interfaces/activity';
+import { Ref } from '~/interfaces/common';
+import { IPage } from '~/interfaces/page';
 
 import loggerFactory from '../../utils/logger';
 
@@ -94,18 +96,8 @@ activitySchema.post('save', function() {
 activitySchema.methods.getNotificationTargetUsers = async function() {
   const User = getModelSafely('User') || require('~/server/models/user')();
   const { user: actionUser, target } = this;
-
-  const [subscribeUsers, unsubscribeUsers] = await Promise.all([
-    Subscription.getSubscription((target as any) as Types.ObjectId),
-    Subscription.getUnsubscription((target as any) as Types.ObjectId),
-  ]);
-
-  const unique = array => Object.values(array.reduce((objects, object) => ({ ...objects, [object.toString()]: object }), {}));
-  const filter = (array, pull) => {
-    const ids = pull.map(object => object.toString());
-    return array.filter(object => !ids.includes(object.toString()));
-  };
-  const notificationUsers = filter(unique([...subscribeUsers]), [...unsubscribeUsers, actionUser]);
+  const subscribedUsers = await Subscription.getSubscription(target as unknown as Ref<IPage>);
+  const notificationUsers = subscribedUsers.filter(item => (item.toString() !== actionUser._id.toString()));
   const activeNotificationUsers = await User.find({
     _id: { $in: notificationUsers },
     status: User.STATUS_ACTIVE,

+ 19 - 21
packages/app/src/server/models/subscription.ts

@@ -1,31 +1,24 @@
+
 import { getOrCreateModel } from '@growi/core';
 import {
   Types, Document, Model, Schema,
 } from 'mongoose';
 
 import { AllSupportedTargetModels } from '~/interfaces/activity';
-import { SubscriptionStatusType, AllSubscriptionStatusType } from '~/interfaces/subscription';
-
-
-export interface ISubscription {
-  user: Types.ObjectId
-  targetModel: string
-  target: Types.ObjectId
-  status: string
-  createdAt: Date
-
-  isSubscribing(): boolean
-  isUnsubscribing(): boolean
-}
+import { Ref } from '~/interfaces/common';
+import { IPage } from '~/interfaces/page';
+import { SubscriptionStatusType, AllSubscriptionStatusType, ISubscription } from '~/interfaces/subscription';
+import { IUser } from '~/interfaces/user';
 
 export interface SubscriptionDocument extends ISubscription, Document {}
 
 export interface SubscriptionModel extends Model<SubscriptionDocument> {
   findByUserIdAndTargetId(userId: Types.ObjectId | string, targetId: Types.ObjectId | string): any
-  upsertSubscription(user: Types.ObjectId, targetModel: string, target: Types.ObjectId, status: string): any
-  subscribeByPageId(user: Types.ObjectId, pageId: Types.ObjectId, status: string): any
-  getSubscription(target: Types.ObjectId): Promise<Types.ObjectId[]>
-  getUnsubscription(target: Types.ObjectId): Promise<Types.ObjectId[]>
+  upsertSubscription(user: Ref<IUser>, targetModel: string, target: Ref<IPage>, status: string): any
+  subscribeByPageId(userId: Types.ObjectId, pageId: Types.ObjectId, status: string): any
+  getSubscription(target: Ref<IPage>): Promise<Ref<IUser>[]>
+  getUnsubscription(target: Ref<IPage>): Promise<Ref<IUser>[]>
+  getSubscriptions(targets: Ref<IPage>[]): Promise<Ref<IUser>[]>
 }
 
 const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
@@ -42,6 +35,7 @@ const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
   },
   target: {
     type: Schema.Types.ObjectId,
+    ref: 'Page',
     refPath: 'targetModel',
     required: true,
   },
@@ -75,16 +69,20 @@ subscriptionSchema.statics.upsertSubscription = function(user, targetModel, targ
   return this.findOneAndUpdate(query, doc, options);
 };
 
-subscriptionSchema.statics.subscribeByPageId = function(user, pageId, status) {
-  return this.upsertSubscription(user, 'Page', pageId, status);
+subscriptionSchema.statics.subscribeByPageId = function(userId, pageId, status) {
+  return this.upsertSubscription(userId, 'Page', pageId, status);
 };
 
-subscriptionSchema.statics.getSubscription = async function(target) {
+subscriptionSchema.statics.getSubscription = async function(target: Ref<IPage>) {
   return this.find({ target, status: SubscriptionStatusType.SUBSCRIBE }).distinct('user');
 };
 
-subscriptionSchema.statics.getUnsubscription = async function(target) {
+subscriptionSchema.statics.getUnsubscription = async function(target: Ref<IPage>) {
   return this.find({ target, status: SubscriptionStatusType.UNSUBSCRIBE }).distinct('user');
 };
 
+subscriptionSchema.statics.getSubscriptions = async function(targets: Ref<IPage>[]) {
+  return this.find({ target: { $in: targets }, status: SubscriptionStatusType.SUBSCRIBE }).distinct('user');
+};
+
 export default getOrCreateModel<SubscriptionDocument, SubscriptionModel>('Subscription', subscriptionSchema);

+ 5 - 0
packages/app/src/server/routes/apiv3/page.js

@@ -591,6 +591,11 @@ module.exports = (crowi) => {
 
       const Revision = crowi.model('Revision');
       revision = await Revision.findById(revisionIdForFind);
+
+      // Error if pageId and revison's pageIds do not match
+      if (page._id.toString() !== revision.pageId.toString()) {
+        return res.apiv3Err(new ErrorV3("Haven't the right to see the page."), 403);
+      }
     }
     catch (err) {
       logger.error('Failed to get page data', err);

+ 8 - 12
packages/app/src/server/routes/apiv3/pages.js

@@ -489,7 +489,7 @@ module.exports = (crowi) => {
    *          409:
    *            description: page path is already existed
    */
-  router.put('/rename', accessTokenParser, loginRequiredStrictly, csrf, addActivity, validator.renamePage, apiV3FormValidator, async(req, res) => {
+  router.put('/rename', accessTokenParser, loginRequiredStrictly, csrf, validator.renamePage, apiV3FormValidator, async(req, res) => {
     const { pageId, revisionId } = req.body;
 
     let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
@@ -501,6 +501,11 @@ module.exports = (crowi) => {
       isMoveMode: req.body.isMoveMode,
     };
 
+    const activityParameters = {
+      ip: req.ip,
+      endpoint: req.originalUrl,
+    };
+
     if (!isCreatablePage(newPagePath)) {
       return res.apiv3Err(new ErrorV3(`Could not use the path '${newPagePath}'`, 'invalid_path'), 409);
     }
@@ -519,6 +524,7 @@ module.exports = (crowi) => {
 
     try {
       page = await Page.findByIdAndViewer(pageId, req.user, null, true);
+      options.isRecursively = page.descendantCount > 0;
 
       if (page == null) {
         return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
@@ -532,15 +538,13 @@ module.exports = (crowi) => {
       if (!page.isEmpty && !page.isUpdatable(revisionId)) {
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
       }
-      renamedPage = await crowi.pageService.renamePage(page, newPagePath, req.user, options);
+      renamedPage = await crowi.pageService.renamePage(page, newPagePath, req.user, options, activityParameters);
     }
     catch (err) {
       logger.error(err);
       return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
     }
-
     const result = { page: serializePageSecurely(renamedPage ?? page) };
-
     try {
       // global notification
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, page, req.user, {
@@ -551,14 +555,6 @@ module.exports = (crowi) => {
       logger.error('Move notification failed', err);
     }
 
-    const activityId = res.locals.activity._id;
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: SupportedAction.ACTION_PAGE_RENAME,
-    };
-    activityEvent.emit('update', activityId, parameters, page);
-
     return res.apiv3(result);
   });
 

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

@@ -172,8 +172,8 @@ module.exports = function(crowi, app) {
   apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   apiV1Router.get('/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
-  apiV1Router.post('/pages.remove'       , loginRequiredStrictly , csrf, addActivity, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
-  apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , csrf, addActivity, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
+  apiV1Router.post('/pages.remove'       , loginRequiredStrictly , csrf, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
+  apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , csrf, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
   apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , csrf, page.api.unlink); // (Avoid from API Token)
   apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, csrf, page.api.duplicate);
   apiV1Router.get('/tags.list'           , accessTokenParser, loginRequired, tag.api.list);

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

@@ -1270,6 +1270,11 @@ module.exports = function(crowi, app) {
 
     const options = {};
 
+    const activityParameters = {
+      ip: req.ip,
+      endpoint: req.originalUrl,
+    };
+
     const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
     if (page == null) {
@@ -1293,7 +1298,7 @@ module.exports = function(crowi, app) {
         if (!crowi.pageService.canDeleteCompletely(page.path, creator, req.user, isRecursively)) {
           return res.json(ApiResponse.error('You can not delete this page completely', 'user_not_admin'));
         }
-        await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);
+        await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively, false, activityParameters);
       }
       else {
         // behave like not found
@@ -1310,7 +1315,7 @@ module.exports = function(crowi, app) {
           return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
         }
 
-        await crowi.pageService.deletePage(page, req.user, options, isRecursively);
+        await crowi.pageService.deletePage(page, req.user, options, isRecursively, activityParameters);
       }
     }
     catch (err) {
@@ -1324,13 +1329,6 @@ module.exports = function(crowi, app) {
     result.isRecursively = isRecursively;
     result.isCompletely = isCompletely;
 
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: isCompletely ? SupportedAction.ACTION_PAGE_DELETE_COMPLETELY : SupportedAction.ACTION_PAGE_DELETE,
-    };
-    activityEvent.emit('update', res.locals.activity._id, parameters, page);
-
     res.json(ApiResponse.success(result));
 
     try {
@@ -1362,13 +1360,19 @@ module.exports = function(crowi, app) {
     // get recursively flag
     const isRecursively = req.body.recursively;
 
+    const activityParameters = {
+      ip: req.ip,
+      endpoint: req.originalUrl,
+    };
+
     let page;
+    let descendantPages;
     try {
       page = await Page.findByIdAndViewer(pageId, req.user);
       if (page == null) {
         throw new Error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden');
       }
-      page = await crowi.pageService.revertDeletedPage(page, req.user, {}, isRecursively);
+      page = await crowi.pageService.revertDeletedPage(page, req.user, {}, isRecursively, activityParameters);
     }
     catch (err) {
       if (err instanceof PathAlreadyExistsError) {
@@ -1382,13 +1386,6 @@ module.exports = function(crowi, app) {
     const result = {};
     result.page = page; // TODO consider to use serializePageSecurely method -- 2018.08.06 Yuki Takei
 
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: SupportedAction.ACTION_PAGE_REVERT,
-    };
-    activityEvent.emit('update', res.locals.activity._id, parameters, page);
-
     return res.json(ApiResponse.success(result));
   };
 

+ 12 - 6
packages/app/src/server/service/activity.ts

@@ -4,11 +4,14 @@ import {
   IActivity, SupportedAction, SupportedActionType, AllSupportedActions, ActionGroupSize,
   AllEssentialActions, AllSmallGroupActions, AllMediumGroupActions, AllLargeGroupActions,
 } from '~/interfaces/activity';
+import { Ref } from '~/interfaces/common';
 import { IPage } from '~/interfaces/page';
+import { IUser } from '~/interfaces/user';
 import Activity from '~/server/models/activity';
 
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
+import { PageDocument } from '../models/page';
 
 
 const logger = loggerFactory('growi:service:ActivityService');
@@ -39,7 +42,7 @@ class ActivityService {
   }
 
   initActivityEventListeners(): void {
-    this.activityEvent.on('update', async(activityId: string, parameters, target?: IPage) => {
+    this.activityEvent.on('update', async(activityId: string, parameters, target?: IPage, descendantsSubscribedUsers?: Ref<IUser>[]) => {
       let activity: IActivity;
       const shoudUpdate = this.shoudUpdateActivity(parameters.action);
 
@@ -52,7 +55,7 @@ class ActivityService {
           return;
         }
 
-        this.activityEvent.emit('updated', activity, target);
+        this.activityEvent.emit('updated', activity, target, descendantsSubscribedUsers);
       }
     });
   }
@@ -96,23 +99,26 @@ class ActivityService {
     }
 
     return Array.from(availableActionsSet);
-  }
+  };
 
   shoudUpdateActivity = function(action: SupportedActionType): boolean {
     return this.getAvailableActions().includes(action);
-  }
+  };
 
   // for GET request
-  createActivity = async function(parameters): Promise<void> {
+  createActivity = async function(parameters): Promise<IActivity | null> {
     const shoudCreateActivity = this.crowi.activityService.shoudUpdateActivity(parameters.action);
     if (shoudCreateActivity) {
+      let activity: IActivity;
       try {
-        await Activity.createByParameters(parameters);
+        activity = await Activity.createByParameters(parameters);
+        return activity;
       }
       catch (err) {
         logger.error('Create activity failed', err);
       }
     }
+    return null;
   };
 
   createTtlIndex = async function() {

+ 21 - 14
packages/app/src/server/service/global-notification/global-notification-mail.js

@@ -20,17 +20,17 @@ class GlobalNotificationMailService {
    * @memberof GlobalNotificationMailService
    *
    * @param {string} event event name triggered
-   * @param {string} path path triggered the event
+   * @param {import('~/server/models/page').PageDocument} page page triggered the event
    * @param {User} triggeredBy user who triggered the event
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    */
-  async fire(event, path, triggeredBy, vars) {
+  async fire(event, page, triggeredBy, vars) {
     const { mailService } = this.crowi;
 
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
-    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, this.type);
+    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, page.path, this.type);
 
-    const option = this.generateOption(event, path, triggeredBy, vars);
+    const option = this.generateOption(event, page, triggeredBy, vars);
 
     await Promise.all(notifications.map((notification) => {
       return mailService.send({ ...option, to: notification.toEmail });
@@ -43,38 +43,45 @@ class GlobalNotificationMailService {
    * @memberof GlobalNotificationMailService
    *
    * @param {string} event event name triggered
-   * @param {string} path path triggered the event
+   * @param {import('~/server/models/page').PageDocument} page path triggered the event
    * @param {User} triggeredBy user triggered the event
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    *
    * @return  {{ subject: string, template: string, vars: object }}
    */
-  generateOption(event, path, triggeredBy, { comment, oldPath }) {
+  generateOption(event, page, triggeredBy, { comment, oldPath }) {
     const defaultLang = this.crowi.configManager.getConfig('crowi', 'app:globalLang');
     // validate for all events
-    if (event == null || path == null || triggeredBy == null) {
+    if (event == null || page == null || triggeredBy == null) {
       throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
     }
 
     const template = nodePath.join(this.crowi.localeDir, `${defaultLang}/notifications/${event}.txt`);
+
+    const path = page.path;
+    const appTitle = this.crowi.appService.getAppTitle();
+    const siteUrl = this.crowi.appService.getSiteUrl();
+    const pageUrl = new URL(page._id, siteUrl);
+
     let subject;
     let vars = {
-      appTitle: this.crowi.appService.getAppTitle(),
+      appTitle,
+      siteUrl,
       path,
       username: triggeredBy.username,
     };
 
     switch (event) {
       case this.event.PAGE_CREATE:
-        subject = `#${event} - ${triggeredBy.username} created ${path}`;
+        subject = `#${event} - ${triggeredBy.username} created ${path} at URL: ${pageUrl}`;
         break;
 
       case this.event.PAGE_EDIT:
-        subject = `#${event} - ${triggeredBy.username} edited ${path}`;
+        subject = `#${event} - ${triggeredBy.username} edited ${path} at URL: ${pageUrl}`;
         break;
 
       case this.event.PAGE_DELETE:
-        subject = `#${event} - ${triggeredBy.username} deleted ${path}`;
+        subject = `#${event} - ${triggeredBy.username} deleted ${path} at URL: ${pageUrl}`;
         break;
 
       case this.event.PAGE_MOVE:
@@ -83,7 +90,7 @@ class GlobalNotificationMailService {
           throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
         }
 
-        subject = `#${event} - ${triggeredBy.username} moved ${oldPath} to ${path}`;
+        subject = `#${event} - ${triggeredBy.username} moved ${oldPath} to ${path} at URL: ${pageUrl}`;
         vars = {
           ...vars,
           oldPath,
@@ -92,7 +99,7 @@ class GlobalNotificationMailService {
         break;
 
       case this.event.PAGE_LIKE:
-        subject = `#${event} - ${triggeredBy.username} liked ${path}`;
+        subject = `#${event} - ${triggeredBy.username} liked ${path} at URL: ${pageUrl}`;
         break;
 
       case this.event.COMMENT:
@@ -101,7 +108,7 @@ class GlobalNotificationMailService {
           throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
         }
 
-        subject = `#${event} - ${triggeredBy.username} commented on ${path}`;
+        subject = `#${event} - ${triggeredBy.username} commented on ${path} at URL: ${pageUrl}`;
         vars = {
           ...vars,
           comment: comment.comment,

+ 1 - 1
packages/app/src/server/service/global-notification/index.js

@@ -45,7 +45,7 @@ class GlobalNotificationService {
     }
 
     await Promise.all([
-      this.gloabalNotificationMail.fire(event, page.path, triggeredBy, vars),
+      this.gloabalNotificationMail.fire(event, page, triggeredBy, vars),
       this.gloabalNotificationSlack.fire(event, page.id, page.path, triggeredBy, vars),
     ]);
   }

+ 22 - 11
packages/app/src/server/service/in-app-notification.ts

@@ -2,6 +2,7 @@ import { subDays } from 'date-fns';
 import { Types } from 'mongoose';
 
 import { AllEssentialActions, SupportedAction } from '~/interfaces/activity';
+import { Ref } from '~/interfaces/common';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import { InAppNotificationStatuses, PaginateResult } from '~/interfaces/in-app-notification';
 import { IPage } from '~/interfaces/page';
@@ -18,6 +19,7 @@ import Subscription from '~/server/models/subscription';
 import loggerFactory from '~/utils/logger';
 
 import Crowi from '../crowi';
+import { PageDocument } from '../models/page';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
 
@@ -51,11 +53,11 @@ export default class InAppNotificationService {
   }
 
   initActivityEventListeners(): void {
-    this.activityEvent.on('updated', async(activity: ActivityDocument, target: IPage) => {
+    this.activityEvent.on('updated', async(activity: ActivityDocument, target: IPage, descendantsSubscribedUsers?: Ref<IUser>[]) => {
       try {
         const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
         if (shouldNotification) {
-          await this.createInAppNotification(activity, target);
+          await this.createInAppNotification(activity, target, descendantsSubscribedUsers);
         }
       }
       catch (err) {
@@ -74,7 +76,7 @@ export default class InAppNotificationService {
           .emit('notificationUpdated');
       });
     }
-  }
+  };
 
   upsertByActivity = async function(
       users: Types.ObjectId[], activity: ActivityDocument, snapshot: string, createdAt?: Date | null,
@@ -110,7 +112,7 @@ export default class InAppNotificationService {
     await InAppNotification.bulkWrite(operations);
     logger.info('InAppNotification bulkWrite has run');
     return;
-  }
+  };
 
   getLatestNotificationsByUser = async(
       userId: Types.ObjectId,
@@ -145,7 +147,7 @@ export default class InAppNotificationService {
       logger.error('Error', err);
       throw new Error(err);
     }
-  }
+  };
 
   read = async function(user: Types.ObjectId): Promise<void> {
     const query = { user, status: STATUS_UNREAD };
@@ -162,7 +164,7 @@ export default class InAppNotificationService {
 
     await InAppNotification.findOneAndUpdate(query, parameters, options);
     return;
-  }
+  };
 
   updateAllNotificationsAsOpened = async function(user: IUser & HasObjectId): Promise<void> {
     const filter = { user: user._id, status: STATUS_UNOPENED };
@@ -170,7 +172,7 @@ export default class InAppNotificationService {
 
     await InAppNotification.updateMany(filter, options);
     return;
-  }
+  };
 
   getUnreadCountByUser = async function(user: Types.ObjectId): Promise<number| undefined> {
     const query = { user, status: STATUS_UNREAD };
@@ -199,17 +201,26 @@ export default class InAppNotificationService {
     return;
   };
 
-  createInAppNotification = async function(activity: ActivityDocument, target: IPage): Promise<void> {
+  createInAppNotification = async function(activity: ActivityDocument, target: IPage, descendantsSubscribedUsers?: Ref<IUser>[]): Promise<void> {
     const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
+    const snapshot = stringifySnapshot(target);
     if (shouldNotification) {
       let mentionedUsers: IUser[] = [];
       if (activity.action === SupportedAction.ACTION_COMMENT_CREATE) {
         mentionedUsers = await this.crowi.commentService.getMentionedUsers(activity.event);
       }
       const notificationTargetUsers = await activity?.getNotificationTargetUsers();
-      const snapshot = stringifySnapshot(target as IPage);
-      await this.upsertByActivity([...notificationTargetUsers, ...mentionedUsers], activity, snapshot);
-      await this.emitSocketIo(notificationTargetUsers);
+      let notificationDescendantsUsers = [];
+      if (descendantsSubscribedUsers != null) {
+        const User = this.crowi.model('User');
+        const descendantsUsers = descendantsSubscribedUsers.filter(item => (item.toString() !== activity.user._id.toString()));
+        notificationDescendantsUsers = await User.find({
+          _id: { $in: descendantsUsers },
+          status: User.STATUS_ACTIVE,
+        }).distinct('_id');
+      }
+      await this.upsertByActivity([...notificationTargetUsers, ...mentionedUsers, ...notificationDescendantsUsers], activity, snapshot);
+      await this.emitSocketIo([...notificationTargetUsers, notificationDescendantsUsers]);
     }
     else {
       throw Error('No activity to notify');

+ 126 - 29
packages/app/src/server/service/page.ts

@@ -6,6 +6,7 @@ import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { ObjectId, QueryCursor } from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { Ref } from '~/interfaces/common';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { HasObjectId } from '~/interfaces/has-object-id';
@@ -18,7 +19,7 @@ import {
 import {
   IPageOperationProcessInfo, IPageOperationProcessData, PageActionStage, PageActionType,
 } from '~/interfaces/page-operation';
-import { IUserHasId } from '~/interfaces/user';
+import { IUser, IUserHasId } from '~/interfaces/user';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import {
   CreateMethod, PageCreateOptions, PageModel, PageDocument, pushRevision, PageQueryBuilder,
@@ -137,10 +138,13 @@ class PageService {
 
   tagEvent: any;
 
+  activityEvent: any;
+
   constructor(crowi) {
     this.crowi = crowi;
     this.pageEvent = crowi.event('page');
     this.tagEvent = crowi.event('tag');
+    this.activityEvent = crowi.event('activity');
 
     // init
     this.initPageEvent();
@@ -348,12 +352,26 @@ class PageService {
       .cursor({ batchSize: BULK_REINDEX_SIZE });
   }
 
-  async renamePage(page, newPagePath, user, options) {
+  async renamePage(page: IPage, newPagePath, user, options, activityParameters): Promise<PageDocument | null> {
     /*
      * Common Operation
      */
     const Page = mongoose.model('Page') as unknown as PageModel;
 
+    const parameters = {
+      ip: activityParameters.ip,
+      endpoint: activityParameters.endpoint,
+      action: page.descendantCount > 0 ? SupportedAction.ACTION_PAGE_RECURSIVELY_RENAME : SupportedAction.ACTION_PAGE_RENAME,
+      user,
+      targetModel: 'Page',
+      target: page,
+      snapshot: {
+        username: user.username,
+      },
+    };
+
+    const activity = await this.crowi.activityService.createActivity(parameters);
+
     const isExist = await Page.exists({ path: newPagePath });
     if (isExist) {
       throw Error(`Page already exists at ${newPagePath}`);
@@ -403,10 +421,9 @@ class PageService {
       logger.error('Failed to create PageOperation document.', err);
       throw err;
     }
-
     let renamedPage: PageDocument | null = null;
     try {
-      renamedPage = await this.renameMainOperation(page, newPagePath, user, options, pageOp._id);
+      renamedPage = await this.renameMainOperation(page, newPagePath, user, options, pageOp._id, activity);
     }
     catch (err) {
       logger.error('Error occurred while running renameMainOperation', err);
@@ -416,11 +433,13 @@ class PageService {
 
       throw err;
     }
-
+    if (page.descendantCount < 1) {
+      this.activityEvent.emit('updated', activity, page);
+    }
     return renamedPage;
   }
 
-  async renameMainOperation(page, newPagePath: string, user, options, pageOpId: ObjectIdLike) {
+  async renameMainOperation(page, newPagePath: string, user, options, pageOpId: ObjectIdLike, activity?): Promise<PageDocument | null> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const updateMetadata = options.updateMetadata || false;
@@ -505,12 +524,12 @@ class PageService {
     /*
      * Sub Operation
      */
-    this.renameSubOperation(page, newPagePath, user, options, renamedPage, pageOp._id);
+    this.renameSubOperation(page, newPagePath, user, options, renamedPage, pageOp._id, activity);
 
     return renamedPage;
   }
 
-  async renameSubOperation(page, newPagePath: string, user, options, renamedPage, pageOpId: ObjectIdLike): Promise<void> {
+  async renameSubOperation(page, newPagePath: string, user, options, renamedPage, pageOpId: ObjectIdLike, activity?): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const exParentId = page.parent;
@@ -518,7 +537,10 @@ class PageService {
     const timerObj = this.crowi.pageOperationService.autoUpdateExpiryDate(pageOpId);
     try {
     // update descendants first
-      await this.renameDescendantsWithStream(page, newPagePath, user, options, false);
+      const descendantsSubscribedSets = new Set();
+      await this.renameDescendantsWithStream(page, newPagePath, user, options, false, descendantsSubscribedSets);
+      const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
+      this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
     }
     catch (err) {
       logger.warn(err);
@@ -549,7 +571,7 @@ class PageService {
     await PageOperation.findByIdAndDelete(pageOpId);
   }
 
-  async resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument): Promise<void> {
+  async resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument, activity?): Promise<void> {
     const isProcessable = pageOp.isProcessable();
     if (!isProcessable) {
       throw Error('This page operation is currently being processed');
@@ -562,7 +584,7 @@ class PageService {
       page, fromPath, toPath, options, user,
     } = pageOp;
 
-    this.fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOp._id, fromPath, toPath);
+    this.fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOp._id, fromPath, toPath, activity);
   }
 
   /**
@@ -570,8 +592,8 @@ class PageService {
    * `renameSubOperation` to restart rename operation
    * `updateDescendantCountOfPagesWithPaths` to fix descendantCount of ancestors
    */
-  private async fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOpId, fromPath, toPath): Promise<void> {
-    await this.renameSubOperation(page, toPath, user, options, renamedPage, pageOpId);
+  private async fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOpId, fromPath, toPath, activity?): Promise<void> {
+    await this.renameSubOperation(page, toPath, user, options, renamedPage, pageOpId, activity);
     const ancestorsPaths = this.crowi.pageOperationService.getAncestorsPathsByFromAndToPath(fromPath, toPath);
     await this.updateDescendantCountOfPagesWithPaths(ancestorsPaths);
   }
@@ -806,7 +828,7 @@ class PageService {
     this.pageEvent.emit('updateMany', pages, user);
   }
 
-  private async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}, shouldUseV4Process = true) {
+  private async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}, shouldUseV4Process = true, descendantsSubscribedSets?) {
     // v4 compatible process
     if (shouldUseV4Process) {
       return this.renameDescendantsWithStreamV4(targetPage, newPagePath, user, options);
@@ -829,6 +851,10 @@ class PageService {
           await renameDescendants(
             batch, user, options, pathRegExp, newPagePathPrefix, shouldUseV4Process,
           );
+          const subscribedUsers = await Subscription.getSubscriptions(batch);
+          subscribedUsers.forEach((eachUser) => {
+            descendantsSubscribedSets.add(eachUser);
+          });
           logger.debug(`Renaming pages progressing: (count=${count})`);
         }
         catch (err) {
@@ -1335,7 +1361,7 @@ class PageService {
   /*
    * Delete
    */
-  async deletePage(page, user, options = {}, isRecursively = false) {
+  async deletePage(page, user, options = {}, isRecursively = false, activityParameters?) {
     /*
      * Common Operation
      */
@@ -1373,6 +1399,20 @@ class PageService {
       await Page.replaceTargetWithPage(page, null, true);
     }
 
+    const parameters = {
+      ip: activityParameters.ip,
+      endpoint: activityParameters.endpoint,
+      action: page.descendantCount > 0 ? SupportedAction.ACTION_PAGE_RECURSIVELY_DELETE : SupportedAction.ACTION_PAGE_DELETE,
+      user,
+      target: page,
+      targetModel: 'Page',
+      snapshot: {
+        username: user.username,
+      },
+    };
+
+    const activity = await this.crowi.activityService.createActivity(parameters);
+
     // Delete target (only updating an existing document's properties )
     let deletedPage;
     if (!page.isEmpty) {
@@ -1416,7 +1456,7 @@ class PageService {
        */
       (async() => {
         try {
-          await this.deleteRecursivelyMainOperation(page, user, pageOp._id);
+          await this.deleteRecursivelyMainOperation(page, user, pageOp._id, activity);
         }
         catch (err) {
           logger.error('Error occurred while running deleteRecursivelyMainOperation.', err);
@@ -1428,6 +1468,9 @@ class PageService {
         }
       })();
     }
+    else {
+      this.activityEvent.emit('updated', activity, page);
+    }
 
     return deletedPage;
   }
@@ -1459,8 +1502,12 @@ class PageService {
     return deletedPage;
   }
 
-  async deleteRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {
-    await this.deleteDescendantsWithStream(page, user, false);
+  async deleteRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike, activity?): Promise<void> {
+    const descendantsSubscribedSets = new Set();
+    await this.deleteDescendantsWithStream(page, user, false, descendantsSubscribedSets);
+
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
+    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
 
     await PageOperation.findByIdAndDelete(pageOpId);
 
@@ -1582,7 +1629,7 @@ class PageService {
   /**
    * Create delete stream and return deleted document count
    */
-  private async deleteDescendantsWithStream(targetPage, user, shouldUseV4Process = true): Promise<number> {
+  private async deleteDescendantsWithStream(targetPage, user, shouldUseV4Process = true, descendantsSubscribedSets?): Promise<number> {
     let readStream;
     if (shouldUseV4Process) {
       readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
@@ -1605,6 +1652,10 @@ class PageService {
         try {
           count += batch.length;
           await deleteDescendants(batch, user);
+          const subscribedUsers = await Subscription.getSubscriptions(batch);
+          subscribedUsers.forEach((eachUser) => {
+            descendantsSubscribedSets.add(eachUser);
+          });
           logger.debug(`Deleting pages progressing: (count=${count})`);
         }
         catch (err) {
@@ -1669,7 +1720,7 @@ class PageService {
     return;
   }
 
-  async deleteCompletely(page, user, options = {}, isRecursively = false, preventEmitting = false) {
+  async deleteCompletely(page, user, options = {}, isRecursively = false, preventEmitting = false, activityParameters?) {
     /*
      * Common Operation
      */
@@ -1699,6 +1750,20 @@ class PageService {
 
     logger.debug('Deleting completely', paths);
 
+    const parameters = {
+      ip: activityParameters.ip,
+      endpoint: activityParameters.endpoint,
+      action: page.descendantCount > 0 ? SupportedAction.ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY : SupportedAction.ACTION_PAGE_DELETE_COMPLETELY,
+      user,
+      target: page,
+      targetModel: 'Page',
+      snapshot: {
+        username: user.username,
+      },
+    };
+
+    const activity = await this.crowi.activityService.createActivity(parameters);
+
     // 1. update descendantCount
     if (isRecursively) {
       const inc = page.isEmpty ? -page.descendantCount : -(page.descendantCount + 1);
@@ -1744,7 +1809,7 @@ class PageService {
        */
       (async() => {
         try {
-          await this.deleteCompletelyRecursivelyMainOperation(page, user, options, pageOp._id);
+          await this.deleteCompletelyRecursivelyMainOperation(page, user, options, pageOp._id, activity);
         }
         catch (err) {
           logger.error('Error occurred while running deleteCompletelyRecursivelyMainOperation.', err);
@@ -1756,12 +1821,18 @@ class PageService {
         }
       })();
     }
+    else {
+      this.activityEvent.emit('updated', activity, page);
+    }
 
     return;
   }
 
-  async deleteCompletelyRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike): Promise<void> {
-    await this.deleteCompletelyDescendantsWithStream(page, user, options, false);
+  async deleteCompletelyRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void> {
+    const descendantsSubscribedSets = new Set();
+    await this.deleteCompletelyDescendantsWithStream(page, user, options, false, descendantsSubscribedSets);
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
+    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
 
     await PageOperation.findByIdAndDelete(pageOpId);
 
@@ -1794,7 +1865,7 @@ class PageService {
   /**
    * Create delete completely stream
    */
-  private async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}, shouldUseV4Process = true): Promise<number> {
+  private async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}, shouldUseV4Process = true, descendantsSubscribedSets?): Promise<number> {
     let readStream;
 
     if (shouldUseV4Process) { // pages don't have parents
@@ -1817,6 +1888,10 @@ class PageService {
         try {
           count += batch.length;
           await deleteMultipleCompletely(batch, user, options);
+          const subscribedUsers = await Subscription.getSubscriptions(batch);
+          subscribedUsers.forEach((eachUser) => {
+            descendantsSubscribedSets.add(eachUser);
+          });
           logger.debug(`Adding pages progressing: (count=${count})`);
         }
         catch (err) {
@@ -1900,13 +1975,27 @@ class PageService {
     }
   }
 
-  async revertDeletedPage(page, user, options = {}, isRecursively = false) {
+  async revertDeletedPage(page, user, options = {}, isRecursively = false, activityParameters?) {
     /*
      * Common Operation
      */
     const Page = this.crowi.model('Page');
     const PageTagRelation = this.crowi.model('PageTagRelation');
 
+    const parameters = {
+      ip: activityParameters.ip,
+      endpoint: activityParameters.endpoint,
+      action: page.descendantCount > 0 ? SupportedAction.ACTION_PAGE_RECURSIVELY_REVERT : SupportedAction.ACTION_PAGE_REVERT,
+      user,
+      target: page,
+      targetModel: 'Page',
+      snapshot: {
+        username: user.username,
+      },
+    };
+
+    const activity = await this.crowi.activityService.createActivity(parameters);
+
     // 1. Separate v4 & v5 process
     const shouldUseV4Process = this.shouldUseV4ProcessForRevert(page);
     if (shouldUseV4Process) {
@@ -1941,6 +2030,7 @@ class PageService {
 
     if (!isRecursively) {
       await this.updateDescendantCountOfAncestors(parent._id, 1, true);
+      this.activityEvent.emit('updated', activity, page);
     }
     else {
       let pageOp;
@@ -1964,7 +2054,7 @@ class PageService {
        */
       (async() => {
         try {
-          await this.revertRecursivelyMainOperation(page, user, options, pageOp._id);
+          await this.revertRecursivelyMainOperation(page, user, options, pageOp._id, activity);
         }
         catch (err) {
           logger.error('Error occurred while running revertRecursivelyMainOperation.', err);
@@ -1980,10 +2070,13 @@ class PageService {
     return updatedPage;
   }
 
-  async revertRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike): Promise<void> {
+  async revertRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
-    await this.revertDeletedDescendantsWithStream(page, user, options, false);
+    const descendantsSubscribedSets = new Set();
+    await this.revertDeletedDescendantsWithStream(page, user, options, false, descendantsSubscribedSets);
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
+    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
 
     const newPath = Page.getRevertDeletedPageName(page.path);
     // normalize parent of descendant pages
@@ -2058,7 +2151,7 @@ class PageService {
   /**
    * Create revert stream
    */
-  private async revertDeletedDescendantsWithStream(targetPage, user, options = {}, shouldUseV4Process = true): Promise<number> {
+  private async revertDeletedDescendantsWithStream(targetPage, user, options = {}, shouldUseV4Process = true, descendantsSubscribedSets?): Promise<number> {
     if (shouldUseV4Process) {
       return this.revertDeletedDescendantsWithStreamV4(targetPage, user, options);
     }
@@ -2073,6 +2166,10 @@ class PageService {
         try {
           count += batch.length;
           await revertDeletedDescendants(batch, user);
+          const subscribedUsers = await Subscription.getSubscriptions(batch);
+          subscribedUsers.forEach((eachUser) => {
+            descendantsSubscribedSets.add(eachUser);
+          });
           logger.debug(`Reverting pages progressing: (count=${count})`);
         }
         catch (err) {

+ 1 - 0
packages/app/src/services/renderer/growi-renderer.ts

@@ -73,6 +73,7 @@ export default class GrowiRenderer {
         }),
       ];
       this.postProcessors = [
+
       ];
     }
 

+ 9 - 0
packages/app/src/styles/_recent-changes.scss

@@ -41,5 +41,14 @@
     .icon-lock {
       font-size: 14px;
     }
+
+    // For truncate-text
+    .flex-grow-1 {
+      min-width: 0;
+    }
+
+    .truncate-text {
+      max-width: fit-content;
+    }
   }
 }

+ 1 - 1
packages/app/test/cypress/integration/10-install/install.spec.ts

@@ -53,7 +53,7 @@ context('Installing', () => {
     cy.getByTestid('btnSubmit').click();
 
     cy.screenshot(`${ssPrefix}-installed`, {
-      blackout: ['#grw-sidebar-contents-wrapper'],
+      blackout: ['#grw-sidebar-contents-wrapper','[data-line="2"]:eq(0) > a > img', '[data-hide-in-vrt=true]'],
     });
   });
 

+ 1 - 1
packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts

@@ -102,7 +102,7 @@ context('Access to sidebar', () => {
     });
 
     cy.get('.grw-pagetree-item-children').eq(0).within(() => {
-      cy.get('.flex-fill > input').type('_newname');
+      cy.getByTestid('closable-text-input').type('_newname');
     });
 
     cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}page-tree-6-rename-page`);

+ 38 - 13
packages/app/test/integration/service/page.test.js

@@ -386,7 +386,8 @@ describe('PageService', () => {
 
   describe('rename page without using renameDescendantsWithStreamSpy', () => {
     test('rename page with different tree with isRecursively [deeper]', async() => {
-      const resultPage = await crowi.pageService.renamePage(parentForRename6, '/parentForRename6/renamedChild', testUser1, { isRecursively: true });
+      const resultPage = await crowi.pageService.renamePage(parentForRename6, '/parentForRename6/renamedChild', testUser1, { isRecursively: true },
+        { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
       const wrongPage = await Page.findOne({ path: '/parentForRename6/renamedChild/renamedChild' });
       const expectPage1 = await Page.findOne({ path: '/parentForRename6/renamedChild' });
       const expectPage2 = await Page.findOne({ path: '/parentForRename6-2021H1' });
@@ -408,7 +409,8 @@ describe('PageService', () => {
 
       // when
       //   rename /level1/level2 --> /level1
-      await crowi.pageService.renamePage(parentForRename7, '/level1', testUser1, { isRecursively: true });
+      await crowi.pageService.renamePage(parentForRename7, '/level1', testUser1, { isRecursively: true },
+        { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
       // then
       expect(await Page.findOne({ path: '/level1' })).not.toBeNull();
@@ -438,7 +440,8 @@ describe('PageService', () => {
 
       test('rename page without options', async() => {
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename1, '/renamed1', testUser2, {});
+        const resultPage = await crowi.pageService.renamePage(parentForRename1,
+          '/renamed1', testUser2, {}, { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
         expect(xssSpy).toHaveBeenCalled();
 
@@ -451,7 +454,8 @@ describe('PageService', () => {
 
       test('rename page with updateMetadata option', async() => {
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename2, '/renamed2', testUser2, { updateMetadata: true });
+        const resultPage = await crowi.pageService.renamePage(parentForRename2, '/renamed2', testUser2, { updateMetadata: true },
+          { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
         expect(xssSpy).toHaveBeenCalled();
 
@@ -464,7 +468,8 @@ describe('PageService', () => {
 
       test('rename page with createRedirectPage option', async() => {
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename3, '/renamed3', testUser2, { createRedirectPage: true });
+        const resultPage = await crowi.pageService.renamePage(parentForRename3, '/renamed3', testUser2, { createRedirectPage: true },
+          { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
         expect(xssSpy).toHaveBeenCalled();
         expect(pageEventSpy).toHaveBeenCalledWith('rename');
@@ -476,7 +481,8 @@ describe('PageService', () => {
 
       test('rename page with isRecursively', async() => {
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename4, '/renamed4', testUser2, { isRecursively: true });
+        const resultPage = await crowi.pageService.renamePage(parentForRename4, '/renamed4', testUser2, { isRecursively: true },
+          { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
@@ -489,7 +495,8 @@ describe('PageService', () => {
 
       test('rename page with different tree with isRecursively', async() => {
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename5, '/parentForRename5/renamedChild', testUser1, { isRecursively: true });
+        const resultPage = await crowi.pageService.renamePage(parentForRename5, '/parentForRename5/renamedChild', testUser1, { isRecursively: true },
+          { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
         const wrongPage = await Page.findOne({ path: '/parentForRename5/renamedChild/renamedChild' });
         const expectPage = await Page.findOne({ path: '/parentForRename5/renamedChild' });
 
@@ -644,7 +651,10 @@ describe('PageService', () => {
     });
 
     test('delete page without options', async() => {
-      const resultPage = await crowi.pageService.deletePage(parentForDelete1, testUser2, { });
+      const resultPage = await crowi.pageService.deletePage(parentForDelete1, testUser2, { }, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/delete',
+      });
 
       expect(getDeletedPageNameSpy).toHaveBeenCalled();
       expect(deleteDescendantsWithStreamSpy).not.toHaveBeenCalled();
@@ -661,7 +671,10 @@ describe('PageService', () => {
     });
 
     test('delete page with isRecursively', async() => {
-      const resultPage = await crowi.pageService.deletePage(parentForDelete2, testUser2, { }, true);
+      const resultPage = await crowi.pageService.deletePage(parentForDelete2, testUser2, { }, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/delete',
+      });
 
       expect(getDeletedPageNameSpy).toHaveBeenCalled();
       expect(deleteDescendantsWithStreamSpy).toHaveBeenCalled();
@@ -731,7 +744,10 @@ describe('PageService', () => {
     });
 
     test('delete completely without options', async() => {
-      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { });
+      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, false, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
 
       expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
       expect(deleteCompletelyDescendantsWithStreamSpy).not.toHaveBeenCalled();
@@ -741,7 +757,10 @@ describe('PageService', () => {
 
 
     test('delete completely with isRecursively', async() => {
-      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, true);
+      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, true, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
 
       expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
       expect(deleteCompletelyDescendantsWithStreamSpy).toHaveBeenCalled();
@@ -764,7 +783,10 @@ describe('PageService', () => {
     });
 
     test('revert deleted page when the redirect from page exists', async() => {
-      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert1, testUser2);
+      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert1, testUser2, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
 
       expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(parentForRevert1.path);
       expect(revertDeletedDescendantsWithStreamSpy).not.toHaveBeenCalled();
@@ -782,7 +804,10 @@ describe('PageService', () => {
         return null;
       });
 
-      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert2, testUser2, {}, true);
+      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert2, testUser2, {}, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
 
       expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(parentForRevert2.path);
       expect(findByPathSpy).toHaveBeenCalledWith('/parentForRevert2');

+ 63 - 21
packages/app/test/integration/service/v5.non-public-page.test.ts

@@ -859,10 +859,10 @@ describe('PageService page operations with non-public pages', () => {
   });
 
   describe('Rename', () => {
-    const renamePage = async(page, newPagePath, user, options) => {
+    const renamePage = async(page, newPagePath, user, options, activityParameters?) => {
       // mock return value
       const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
-      const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options);
+      const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options, activityParameters);
 
       // retrieve the arguments passed when calling method renameSubOperation inside renamePage method
       const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
@@ -894,7 +894,11 @@ describe('PageService page operations with non-public pages', () => {
 
       const newPathForPage2 = '/np_rename1_destination/np_rename2';
       const newPathForPage3 = '/np_rename1_destination/np_rename2/np_rename3';
-      await renamePage(_page2, newPathForPage2, npDummyUser2, {});
+      await renamePage(_page2, newPathForPage2, npDummyUser2, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+        activityId: '62e291bc10e0ab61bd691794',
+      });
 
       const pageD = await Page.findOne({ path: _pathD, ..._propertiesD });
       const page2 = await Page.findOne({ path: _path2, ..._properties2 }); // not exist
@@ -930,7 +934,11 @@ describe('PageService page operations with non-public pages', () => {
       const newPathForPage3 = '/np_rename4_destination/np_rename5/np_rename6';
       let isThrown = false;
       try {
-        await renamePage(_page2, newPathForPage2, dummyUser1, {});
+        await renamePage(_page2, newPathForPage2, dummyUser1, {}, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+          activityId: '62e291bc10e0ab61bd691794',
+        });
       }
       catch (err) {
         isThrown = true;
@@ -958,7 +966,11 @@ describe('PageService page operations with non-public pages', () => {
 
       const newPathForPage2 = '/np_rename7_destination/np_rename8';
       const newpathForPage3 = '/np_rename7_destination/np_rename8/np_rename9';
-      await renamePage(_page2, newPathForPage2, npDummyUser1, { isRecursively: true });
+      await renamePage(_page2, newPathForPage2, npDummyUser1, { isRecursively: true }, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+        activityId: '62e291bc10e0ab61bd691794',
+      });
 
       const page2 = await Page.findOne({ path: _path2 }); // not exist
       const page3 = await Page.findOne({ path: _path3 }); // not renamed thus exist
@@ -1092,10 +1104,10 @@ describe('PageService page operations with non-public pages', () => {
   });
   describe('Delete', () => {
 
-    const deletePage = async(page, user, options, isRecursively) => {
+    const deletePage = async(page, user, options, isRecursively, activityParameters?) => {
       const mockedDeleteRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteRecursivelyMainOperation').mockReturnValue(null);
 
-      const deletedPage = await crowi.pageService.deletePage(page, user, options, isRecursively);
+      const deletedPage = await crowi.pageService.deletePage(page, user, options, isRecursively, activityParameters);
 
       const argsForDeleteRecursivelyMainOperation = mockedDeleteRecursivelyMainOperation.mock.calls[0];
 
@@ -1114,7 +1126,10 @@ describe('PageService page operations with non-public pages', () => {
         expect(_pageT).toBeTruthy();
 
         const isRecursively = false;
-        await deletePage(_pageT, dummyUser1, {}, isRecursively);
+        await deletePage(_pageT, dummyUser1, {}, isRecursively, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
         const pageT = await Page.findOne({ path: `/trash${_pathT}` });
         const pageN = await Page.findOne({ path: _pathT }); // should not exist
@@ -1131,7 +1146,10 @@ describe('PageService page operations with non-public pages', () => {
         expect(_page1).toBeTruthy();
 
         const isRecursively = false;
-        await deletePage(_page1, npDummyUser1, {}, isRecursively);
+        await deletePage(_page1, npDummyUser1, {}, isRecursively, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
         const pageN = await Page.findOne({ path: _path, grantedGroup: groupIdA });
         const page1 = await Page.findOne({ path: `/trash${_path}`, grantedGroup: groupIdA });
@@ -1157,7 +1175,10 @@ describe('PageService page operations with non-public pages', () => {
         expect(_pageR).toBeTruthy();
 
         const isRecursively = true;
-        await deletePage(_pageT, npDummyUser1, {}, isRecursively);
+        await deletePage(_pageT, npDummyUser1, {}, isRecursively, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
         const pageTNotExist = await Page.findOne({ path: _pathT, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA }); // A should not exist
         const page1NotExist = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB }); // B should not exist
@@ -1187,10 +1208,10 @@ describe('PageService page operations with non-public pages', () => {
 
   });
   describe('Delete completely', () => {
-    const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false) => {
+    const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false, activityParameters?) => {
       const mockedDeleteCompletelyRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteCompletelyRecursivelyMainOperation').mockReturnValue(null);
 
-      await crowi.pageService.deleteCompletely(page, user, options, isRecursively, preventEmitting);
+      await crowi.pageService.deleteCompletely(page, user, options, isRecursively, preventEmitting, activityParameters);
 
       const argsForDeleteCompletelyRecursivelyMainOperation = mockedDeleteCompletelyRecursivelyMainOperation.mock.calls[0];
 
@@ -1209,7 +1230,10 @@ describe('PageService page operations with non-public pages', () => {
         const _page = await Page.findOne({ path: _path, grant: Page.GRANT_RESTRICTED });
         expect(_page).toBeTruthy();
 
-        await deleteCompletely(_page, dummyUser1, {}, false);
+        await deleteCompletely(_page, dummyUser1, {}, false, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
         const page = await Page.findOne({ path: _path, grant: Page.GRANT_RESTRICTED });
         expect(page).toBeNull();
@@ -1221,7 +1245,10 @@ describe('PageService page operations with non-public pages', () => {
         const _page = await Page.findOne({ path: _path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
         expect(_page).toBeTruthy();
 
-        await deleteCompletely(_page, npDummyUser1, {}, false);
+        await deleteCompletely(_page, npDummyUser1, {}, false, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
         const page = await Page.findOne({ path: _path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
         expect(page).toBeNull();
@@ -1241,7 +1268,10 @@ describe('PageService page operations with non-public pages', () => {
         expect(_page3).toBeTruthy();
         expect(_page4).toBeTruthy();
 
-        await deleteCompletely(_page1, npDummyUser1, {}, true);
+        await deleteCompletely(_page1, npDummyUser1, {}, true, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
         const page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
         const page2 = await Page.findOne({ path: _path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB });
@@ -1256,10 +1286,10 @@ describe('PageService page operations with non-public pages', () => {
     });
   });
   describe('revert', () => {
-    const revertDeletedPage = async(page, user, options = {}, isRecursively = false) => {
+    const revertDeletedPage = async(page, user, options = {}, isRecursively = false, activityParameters?) => {
       // mock return value
       const mockedRevertRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'revertRecursivelyMainOperation').mockReturnValue(null);
-      const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively);
+      const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively, activityParameters);
 
       const argsForRecursivelyMainOperation = mockedRevertRecursivelyMainOperation.mock.calls[0];
 
@@ -1282,7 +1312,10 @@ describe('PageService page operations with non-public pages', () => {
       expect(tag).toBeTruthy();
       expect(deletedPageTagRelation).toBeTruthy();
 
-      await revertDeletedPage(trashedPage, dummyUser1, {}, false);
+      await revertDeletedPage(trashedPage, dummyUser1, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
       const revertedPage = await Page.findOne({ path: '/np_revert1' });
       const deltedPageBeforeRevert = await Page.findOne({ path: '/trash/np_revert1' });
@@ -1309,7 +1342,10 @@ describe('PageService page operations with non-public pages', () => {
       expect(tag).toBeTruthy();
       expect(deletedPageTagRelation).toBeTruthy();
 
-      await revertDeletedPage(trashedPage, user1, {}, false);
+      await revertDeletedPage(trashedPage, user1, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
 
       const revertedPage = await Page.findOne({ path: '/np_revert2' });
       const trashedPageBR = await Page.findOne({ path: beforeRevertPath });
@@ -1337,7 +1373,10 @@ describe('PageService page operations with non-public pages', () => {
       expect(revision1).toBeTruthy();
       expect(revision2).toBeTruthy();
 
-      await revertDeletedPage(trashedPage1, npDummyUser2, {}, true);
+      await revertDeletedPage(trashedPage1, npDummyUser2, {}, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
 
       const revertedPage = await Page.findOne({ path: '/np_revert3' });
       const middlePage = await Page.findOne({ path: '/np_revert3/middle' });
@@ -1376,7 +1415,10 @@ describe('PageService page operations with non-public pages', () => {
       expect(user).toBeTruthy();
       expect(nonExistantPage3).toBeNull();
 
-      await revertDeletedPage(trashedPage1, user, {}, true);
+      await revertDeletedPage(trashedPage1, user, {}, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
       const revertedPage1 = await Page.findOne({ path: '/np_revert5' });
       const newlyCreatedPage = await Page.findOne({ path: '/np_revert5/middle' });
       const revertedPage2 = await Page.findOne({ path: '/np_revert5/middle/np_revert6' });

+ 14 - 4
packages/app/test/integration/service/v5.page.test.ts

@@ -489,9 +489,9 @@ describe('Test page service methods', () => {
   });
 
   describe('restart renameOperation', () => {
-    const resumeRenameSubOperation = async(renamePage, pageOp) => {
+    const resumeRenameSubOperation = async(renamePage, pageOp, activity?) => {
       const mockedPathsAndDescendantCountOfAncestors = jest.spyOn(crowi.pageService, 'fixPathsAndDescendantCountOfAncestors').mockReturnValue(null);
-      await crowi.pageService.resumeRenameSubOperation(renamePage, pageOp);
+      await crowi.pageService.resumeRenameSubOperation(renamePage, pageOp, activity);
 
       const argsForRenameSubOperation = mockedPathsAndDescendantCountOfAncestors.mock.calls[0];
 
@@ -512,6 +512,9 @@ describe('Test page service methods', () => {
       const path2 = '/resume_rename_0/resume_rename_1/resume_rename_2';
       const path3 = '/resume_rename_0/resume_rename_1/resume_rename_2/resume_rename_3';
 
+      // activity options
+      const activity = 'randomActivityId';
+
       // page
       const _page0 = await Page.findOne({ path: _path0 });
       const _page1 = await Page.findOne({ path: _path1 });
@@ -536,7 +539,7 @@ describe('Test page service methods', () => {
       expect(_pageOperation).toBeTruthy();
 
       // rename
-      await resumeRenameSubOperation(_page1, _pageOperation);
+      await resumeRenameSubOperation(_page1, _pageOperation, activity);
 
       // page
       const page0 = await Page.findById(_page0._id);
@@ -573,6 +576,13 @@ describe('Test page service methods', () => {
       const path1 = '/resume_rename_8/resume_rename_9';
       const path2 = '/resume_rename_8/resume_rename_9/resume_rename_10';
 
+      // activity options
+      const activityParameters = {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+        activityId: '62e291bc10e0ab61bd691794',
+      };
+
       // page
       const _page0 = await Page.findOne({ path: _path0 });
       const _page1 = await Page.findOne({ path: _path1 });
@@ -594,7 +604,7 @@ describe('Test page service methods', () => {
       expect(_pageOperation).toBeTruthy();
 
       // rename
-      await resumeRenameSubOperation(_page1, _pageOperation);
+      await resumeRenameSubOperation(_page1, _pageOperation, activityParameters);
 
       // page
       const page0 = await Page.findById(_page0._id);

+ 127 - 36
packages/app/test/integration/service/v5.public-page.test.ts

@@ -429,6 +429,10 @@ describe('PageService page operations with only public pages', () => {
           createRedirectPage: false,
           updateMetadata: true,
         },
+        activityParameters: {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        },
         unprocessableExpiryDate: null,
       },
     ]);
@@ -1139,10 +1143,10 @@ describe('PageService page operations with only public pages', () => {
 
   describe('Rename', () => {
 
-    const renamePage = async(page, newPagePath, user, options) => {
+    const renamePage = async(page, newPagePath, user, options, activityParameters?) => {
       // mock return value
       const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
-      const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options);
+      const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options, activityParameters);
 
       // retrieve the arguments passed when calling method renameSubOperation inside renamePage method
       const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
@@ -1159,7 +1163,7 @@ describe('PageService page operations with only public pages', () => {
     /**
      * This function only execute renameMainOperation. renameSubOperation is basically omitted(only return null)
      */
-    const renameMainOperation = async(page, newPagePath, user, options) => {
+    const renameMainOperation = async(page, newPagePath, user, options, activityParameters?) => {
       // create page operation from target page
       const pageOp = await PageOperation.create({
         actionType: PageActionType.Rename,
@@ -1173,7 +1177,7 @@ describe('PageService page operations with only public pages', () => {
 
       // mock return value
       const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
-      const renamedPage = await crowi.pageService.renameMainOperation(page, newPagePath, user, options, pageOp._id);
+      const renamedPage = await crowi.pageService.renameMainOperation(page, newPagePath, user, options, pageOp._id, activityParameters);
 
       // restores the original implementation
       mockedRenameSubOperation.mockRestore();
@@ -1185,7 +1189,10 @@ describe('PageService page operations with only public pages', () => {
       expect(rootPage).toBeTruthy();
       let isThrown = false;
       try {
-        await crowi.pageService.renamePage(rootPage, '/new_root', dummyUser1, {});
+        await crowi.pageService.renamePage(rootPage, '/new_root', dummyUser1, {}, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
       }
       catch (err) {
         isThrown = true;
@@ -1201,7 +1208,10 @@ describe('PageService page operations with only public pages', () => {
       expect(parentPage).toBeTruthy();
 
       const newPath = '/v5_ParentForRename1/renamedChildForRename1';
-      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
       const childPageBeforeRename = await Page.findOne({ path: '/v5_ChildForRename1' });
 
       expect(xssSpy).toHaveBeenCalled();
@@ -1219,7 +1229,10 @@ describe('PageService page operations with only public pages', () => {
       expect(parentPage.isEmpty).toBe(true);
 
       const newPath = '/v5_ParentForRename2/renamedChildForRename2';
-      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
       const childPageBeforeRename = await Page.findOne({ path: '/v5_ChildForRename2' });
 
       expect(xssSpy).toHaveBeenCalled();
@@ -1238,7 +1251,10 @@ describe('PageService page operations with only public pages', () => {
 
       const newPath = '/v5_ParentForRename3/renamedChildForRename3';
       const oldUpdateAt = childPage.updatedAt;
-      const renamedPage = await renamePage(childPage, newPath, dummyUser2, { updateMetadata: true });
+      const renamedPage = await renamePage(childPage, newPath, dummyUser2, { updateMetadata: true }, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
       expect(xssSpy).toHaveBeenCalled();
       expect(renamedPage.path).toBe(newPath);
@@ -1255,7 +1271,10 @@ describe('PageService page operations with only public pages', () => {
 
       const oldPath = childPage.path;
       const newPath = '/v5_ParentForRename4/renamedChildForRename4';
-      const renamedPage = await renamePage(childPage, newPath, dummyUser2, { createRedirectPage: true });
+      const renamedPage = await renamePage(childPage, newPath, dummyUser2, { createRedirectPage: true }, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
       const pageRedirect = await PageRedirect.findOne({ fromPath: oldPath, toPath: renamedPage.path });
 
       expect(xssSpy).toHaveBeenCalled();
@@ -1274,7 +1293,10 @@ describe('PageService page operations with only public pages', () => {
       expect(grandchild).toBeTruthy();
 
       const newPath = '/v5_ParentForRename5/renamedChildForRename5';
-      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
       // find child of renamed page
       const renamedGrandchild = await Page.findOne({ parent: renamedPage._id });
       const childPageBeforeRename = await Page.findOne({ path: '/v5_ChildForRename5' });
@@ -1300,7 +1322,10 @@ describe('PageService page operations with only public pages', () => {
       expect(grandchild).toBeTruthy();
 
       const newPath = '/v5_ParentForRename7/renamedChildForRename7';
-      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
       const grandchildAfterRename = await Page.findOne({ parent: renamedPage._id });
       const grandchildBeforeRename = await Page.findOne({ path: '/v5_ChildForRename7/v5_GrandchildForRename7' });
 
@@ -1320,7 +1345,10 @@ describe('PageService page operations with only public pages', () => {
       const newPath = '/v5_ParentForRename9';
       let isThrown;
       try {
-        await renamePage(page, newPath, dummyUser1, {});
+        await renamePage(page, newPath, dummyUser1, {}, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
       }
       catch (err) {
         isThrown = true;
@@ -1339,7 +1367,10 @@ describe('PageService page operations with only public pages', () => {
 
       const newParentalPath = '/v5_pageForRename17/v5_pageForRename18';
       const newPath = newParentalPath + page1.path;
-      await renamePage(page1, newPath, dummyUser1, {});
+      await renamePage(page1, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
       const renamedPage = await Page.findOne({ path: newParentalPath + initialPathForPage1 });
       const renamedPageChild = await Page.findOne({ path: newParentalPath + initialPathForPage2 });
@@ -1376,7 +1407,10 @@ describe('PageService page operations with only public pages', () => {
 
       const newParentalPath = '/v5_pageForRename19/v5_pageForRename20';
       const newPath = newParentalPath + page1.path;
-      await renamePage(page1, newPath, dummyUser1, {});
+      await renamePage(page1, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
       const renamedPage = await Page.findOne({ path: newParentalPath + initialPathForPage1 });
       const renamedPageChild = await Page.findOne({ path: newParentalPath + initialPathForPage2 });
@@ -1417,7 +1451,10 @@ describe('PageService page operations with only public pages', () => {
       const newParentalPath = '/v5_pageForRename21/v5_pageForRename22/v5_pageForRename23';
       const newPath = newParentalPath + page1.path;
 
-      await renamePage(page1, newPath, dummyUser1, {});
+      await renamePage(page1, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
       const renamedPage = await Page.findOne({ path: newParentalPath + initialPathForPage1 });
       const renamedPageChild = await Page.findOne({ path: newParentalPath + initialPathForPage2 });
@@ -1519,7 +1556,11 @@ describe('PageService page operations with only public pages', () => {
       expect(_page1.descendantCount).toBe(0);
 
       // renameSubOperation only
-      await crowi.pageService.renameSubOperation(_page1, newPath, dummyUser1, {}, _page1, pageOperation._id);
+      await crowi.pageService.renameSubOperation(_page1, newPath, dummyUser1, {}, _page1, pageOperation._id, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+        activityId: '62e291bc10e0ab61bd691794',
+      });
 
       // page
       const page0 = await Page.findById(_page0._id); // new parent
@@ -1561,7 +1602,10 @@ describe('PageService page operations with only public pages', () => {
       expect(_page1.descendantCount).toBe(1);
       expect(_page2.descendantCount).toBe(0);
 
-      await renamePage(_page1, newPath, dummyUser1, {});
+      await renamePage(_page1, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
       const page0 = await Page.findById(_page0._id); // new parent
       const page1 = await Page.findById(_page1._id); // renamed
@@ -1768,10 +1812,10 @@ describe('PageService page operations with only public pages', () => {
     });
   });
   describe('Delete', () => {
-    const deletePage = async(page, user, options, isRecursively) => {
+    const deletePage = async(page, user, options, isRecursively, activityParameters?) => {
       const mockedDeleteRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteRecursivelyMainOperation').mockReturnValue(null);
 
-      const deletedPage = await crowi.pageService.deletePage(page, user, options, isRecursively);
+      const deletedPage = await crowi.pageService.deletePage(page, user, options, isRecursively, activityParameters);
 
       const argsForDeleteRecursivelyMainOperation = mockedDeleteRecursivelyMainOperation.mock.calls[0];
 
@@ -1787,7 +1831,12 @@ describe('PageService page operations with only public pages', () => {
     test('Should NOT delete root page', async() => {
       let isThrown;
       expect(rootPage).toBeTruthy();
-      try { await deletePage(rootPage, dummyUser1, {}, false) }
+      try {
+        await deletePage(rootPage, dummyUser1, {}, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/delete',
+        });
+      }
       catch (err) { isThrown = true }
 
       const page = await Page.findOne({ path: '/' });
@@ -1801,7 +1850,12 @@ describe('PageService page operations with only public pages', () => {
       expect(trashedPage).toBeTruthy();
 
       let isThrown;
-      try { await deletePage(trashedPage, dummyUser1, {}, false) }
+      try {
+        await deletePage(trashedPage, dummyUser1, {}, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/delete',
+        });
+      }
       catch (err) { isThrown = true }
 
       const page = await Page.findOne({ path: '/trash/v5_PageForDelete1' });
@@ -1814,7 +1868,12 @@ describe('PageService page operations with only public pages', () => {
       const dummyUser1Page = await Page.findOne({ path: '/user/v5DummyUser1' });
       expect(dummyUser1Page).toBeTruthy();
       let isThrown;
-      try { await deletePage(dummyUser1Page, dummyUser1, {}, false) }
+      try {
+        await deletePage(dummyUser1Page, dummyUser1, {}, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/delete',
+        });
+      }
       catch (err) { isThrown = true }
 
       const page = await Page.findOne({ path: '/user/v5DummyUser1' });
@@ -1826,7 +1885,10 @@ describe('PageService page operations with only public pages', () => {
     test('Should delete single page', async() => {
       const pageToDelete = await Page.findOne({ path: '/v5_PageForDelete2' });
       expect(pageToDelete).toBeTruthy();
-      const deletedPage = await deletePage(pageToDelete, dummyUser1, {}, false);
+      const deletedPage = await deletePage(pageToDelete, dummyUser1, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/delete',
+      });
       const page = await Page.findOne({ path: '/v5_PageForDelete2' });
 
       expect(page).toBeNull();
@@ -1842,7 +1904,10 @@ describe('PageService page operations with only public pages', () => {
       expect(parentPage).toBeTruthy();
       expect(childPage).toBeTruthy();
       expect(grandchildPage).toBeTruthy();
-      const deletedParentPage = await deletePage(parentPage, dummyUser1, {}, true);
+      const deletedParentPage = await deletePage(parentPage, dummyUser1, {}, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/delete',
+      });
       const deletedChildPage = await Page.findOne({ path: '/trash/v5_PageForDelete3/v5_PageForDelete4' });
       const deletedGrandchildPage = await Page.findOne({ path: '/trash/v5_PageForDelete3/v5_PageForDelete4/v5_PageForDelete5' });
 
@@ -1869,7 +1934,10 @@ describe('PageService page operations with only public pages', () => {
       expect(tag2).toBeTruthy();
       expect(pageRelation1).toBeTruthy();
       expect(pageRelation2).toBeTruthy();
-      const deletedPage = await deletePage(pageToDelete, dummyUser1, {}, false);
+      const deletedPage = await deletePage(pageToDelete, dummyUser1, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/delete',
+      });
       const page = await Page.findOne({ path: '/v5_PageForDelete6' });
       const deletedTagRelation1 = await PageTagRelation.findOne({ _id: pageRelation1._id });
       const deletedTagRelation2 = await PageTagRelation.findOne({ _id: pageRelation2._id });
@@ -1881,10 +1949,10 @@ describe('PageService page operations with only public pages', () => {
     });
   });
   describe('Delete completely', () => {
-    const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false) => {
+    const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false, activityParameters?) => {
       const mockedDeleteCompletelyRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteCompletelyRecursivelyMainOperation').mockReturnValue(null);
 
-      await crowi.pageService.deleteCompletely(page, user, options, isRecursively, preventEmitting);
+      await crowi.pageService.deleteCompletely(page, user, options, isRecursively, preventEmitting, activityParameters);
 
       const argsForDeleteCompletelyRecursivelyMainOperation = mockedDeleteCompletelyRecursivelyMainOperation.mock.calls[0];
 
@@ -1900,7 +1968,12 @@ describe('PageService page operations with only public pages', () => {
     test('Should NOT completely delete root page', async() => {
       expect(rootPage).toBeTruthy();
       let isThrown;
-      try { await deleteCompletely(rootPage, dummyUser1, {}, false) }
+      try {
+        await deleteCompletely(rootPage, dummyUser1, {}, false, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/deletecompletely',
+        });
+      }
       catch (err) { isThrown = true }
       const page = await Page.findOne({ path: '/' });
       expect(page).toBeTruthy();
@@ -1910,7 +1983,10 @@ describe('PageService page operations with only public pages', () => {
       const page = await Page.findOne({ path: '/v5_PageForDeleteCompletely1' });
       expect(page).toBeTruthy();
 
-      await deleteCompletely(page, dummyUser1, {}, false);
+      await deleteCompletely(page, dummyUser1, {}, false, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
       const deletedPage = await Page.findOne({ _id: page._id, path: '/v5_PageForDeleteCompletely1' });
 
       expect(deletedPage).toBeNull();
@@ -1943,7 +2019,10 @@ describe('PageService page operations with only public pages', () => {
       expect(shareLink1).toBeTruthy();
       expect(shareLink2).toBeTruthy();
 
-      await deleteCompletely(parentPage, dummyUser1, {}, true);
+      await deleteCompletely(parentPage, dummyUser1, {}, true, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
       const deletedPages = await Page.find({ _id: { $in: [parentPage._id, childPage._id, grandchildPage._id] } });
       const deletedRevisions = await Revision.find({ pageId: { $in: [parentPage._id, grandchildPage._id] } });
       const tags = await Tag.find({ _id: { $in: [tag1?._id, tag2?._id] } });
@@ -1975,7 +2054,10 @@ describe('PageService page operations with only public pages', () => {
       const revision = await Revision.findOne({ pageId: page._id });
       expect(page).toBeTruthy();
       expect(revision).toBeTruthy();
-      await deleteCompletely(page, dummyUser1, {}, false);
+      await deleteCompletely(page, dummyUser1, {}, false, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
       const deltedPage = await Page.findOne({ _id: page._id });
       const deltedRevision = await Revision.findOne({ _id: revision._id });
 
@@ -1990,7 +2072,10 @@ describe('PageService page operations with only public pages', () => {
       expect(childPage).toBeTruthy();
       expect(grandchildPage).toBeTruthy();
 
-      await deleteCompletely(childPage, dummyUser1, {}, false);
+      await deleteCompletely(childPage, dummyUser1, {}, false, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
       const parentPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6' });
       const childPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7' });
       const grandchildPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7/v5_PageForDeleteCompletely8' });
@@ -2008,10 +2093,10 @@ describe('PageService page operations with only public pages', () => {
     });
   });
   describe('revert', () => {
-    const revertDeletedPage = async(page, user, options = {}, isRecursively = false) => {
+    const revertDeletedPage = async(page, user, options = {}, isRecursively = false, activityParameters?) => {
       // mock return value
       const mockedRevertRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'revertRecursivelyMainOperation').mockReturnValue(null);
-      const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively);
+      const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively, activityParameters);
 
       const argsForRecursivelyMainOperation = mockedRevertRecursivelyMainOperation.mock.calls[0];
 
@@ -2035,7 +2120,10 @@ describe('PageService page operations with only public pages', () => {
       expect(tag).toBeTruthy();
       expect(deletedPageTagRelation).toBeTruthy();
 
-      const revertedPage = await revertDeletedPage(deletedPage, dummyUser1, {}, false);
+      const revertedPage = await revertDeletedPage(deletedPage, dummyUser1, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
       const pageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag?._id });
 
       expect(revertedPage.parent).toStrictEqual(rootPage._id);
@@ -2055,7 +2143,10 @@ describe('PageService page operations with only public pages', () => {
       expect(revision1).toBeTruthy();
       expect(revision2).toBeTruthy();
 
-      const revertedPage1 = await revertDeletedPage(deletedPage1, dummyUser1, {}, true);
+      const revertedPage1 = await revertDeletedPage(deletedPage1, dummyUser1, {}, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
       const revertedPage2 = await Page.findOne({ _id: deletedPage2._id });
       const newlyCreatedPage = await Page.findOne({ path: '/v5_revert2/v5_revert3' });
 

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "5.1.3",
+  "version": "5.1.4-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "5.1.3",
+  "version": "5.1.4-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-attachment-refs",
-  "version": "5.1.3",
+  "version": "5.1.4-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "5.1.3",
+  "version": "5.1.4-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "5.1.3",
+  "version": "5.1.4-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "5.1.3",
+  "version": "5.1.4-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "5.1.3",
+  "version": "5.1.4-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.1.3",
+    "@growi/slack": "^5.1.4-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "5.1.3",
+  "version": "5.1.4-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [