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

Merge branch 'master' into imprv/gw4933-install-bolt-and-use-customed-reciever

# Conflicts:
#	package.json
#	yarn.lock
yusuketk 5 лет назад
Родитель
Сommit
57267b021d
46 измененных файлов с 2051 добавлено и 464 удалено
  1. 32 3
      CHANGES.md
  2. 5 7
      README.md
  3. 1 0
      config/env.dev.js
  4. 5 2
      package.json
  5. 10 4
      src/client/js/components/ContentLinkButtons.jsx
  6. 5 3
      src/client/js/components/EmptyTrashModal.jsx
  7. 23 2
      src/client/js/components/InstallerForm.jsx
  8. 6 5
      src/client/js/components/Navbar/AuthorInfo.jsx
  9. 10 2
      src/client/js/components/Page/TrashPageAlert.jsx
  10. 14 3
      src/client/js/components/PageEditor/GridEditorUtil.js
  11. 10 11
      src/client/js/components/PageEditor/MarkdownDrawioUtil.js
  12. 10 1
      src/client/js/components/StaffCredit/Contributor.js
  13. 14 0
      src/client/js/services/AdminAppContainer.js
  14. 2 0
      src/client/js/services/PageContainer.js
  15. 2 0
      src/server/crowi/express-init.js
  16. 12 7
      src/server/crowi/index.js
  17. 3 0
      src/server/events/page.js
  18. 12 4
      src/server/middlewares/application-installed.js
  19. 2 2
      src/server/middlewares/application-not-installed.js
  20. 11 0
      src/server/middlewares/promster.js
  21. 8 204
      src/server/models/page.js
  22. 1 2
      src/server/models/user-group.js
  23. 11 20
      src/server/routes/apiv3/pages.js
  24. 1 11
      src/server/routes/attachment.js
  25. 2 2
      src/server/routes/hackmd.js
  26. 34 33
      src/server/routes/index.js
  27. 1 1
      src/server/routes/installer.js
  28. 7 25
      src/server/routes/page.js
  29. 20 17
      src/server/service/app.js
  30. 23 3
      src/server/service/attachment.js
  31. 18 0
      src/server/service/config-loader.js
  32. 18 1
      src/server/service/file-uploader/aws.js
  33. 17 12
      src/server/service/file-uploader/gcs.js
  34. 16 3
      src/server/service/file-uploader/gridfs.js
  35. 6 0
      src/server/service/file-uploader/local.js
  36. 5 0
      src/server/service/file-uploader/none.js
  37. 4 0
      src/server/service/file-uploader/uploader.js
  38. 684 30
      src/server/service/page.js
  39. 47 6
      src/server/service/search-delegator/elasticsearch.js
  40. 3 0
      src/server/service/search.js
  41. 1 1
      src/server/views/installer.html
  42. 1 1
      src/server/views/layout/layout.html
  43. 1 1
      src/server/views/login.html
  44. 2 0
      src/server/views/widget/page_content.html
  45. 723 0
      src/test/service/page.test.js
  46. 208 35
      yarn.lock

+ 32 - 3
CHANGES.md

@@ -1,10 +1,39 @@
 # CHANGES
 # CHANGES
 
 
-## v4.2.6-RC
+## v4.2.8-RC
 
 
-* 
+* Fix: Pass app title value through the XSS filter
+* Fix: Fixed not being able to update ses settings
+    * Introduced by v4.2.0
+* Fix: Fixed the display of updtedAt and createdAt being reversed
+* Improvement: Improved page control performance with stream and bulk
+    * rename, duplicate, delete, deleteCompletely, revrtDeleted
+* Fix: Failed to save temporaryUrlCached with using gcs
+    * Introduced by v4.2.3
+
+## v4.2.7
+
+* Fix: Installer doesn't work on Chrome
+
+## v4.2.6
+
+* Feature: Add a button to jump to Comments section
+* Feature: Paste Bootstrap4 Grid HTML with GUI
+* Feature: Disable auto formating table option
+* Improvement: Layout of Edit Link Modal
+* Improvement: Focus to the first input when modal is opened
+* Improvement: Preview layout in edit mode
+* Improvement: Install process under redundant environment
+* Improvement: Add contributors
+* Fix: Upgrading to v4.x failed when the user uses Kibela Layout
+    * Introduced by v4.2.0
+* Fix: diagrams.net (draw.io) errors
+* Fix: Navbar is not rendered on old iOS
+* Support: Expose metrics with Promster
+* Support: Upgrade libs
+    * axios
 
 
-## v4.2.5-RC
+## v4.2.5
 
 
 * Improvement: Invoke garbage collection when reindex all pages by elasticsearch
 * Improvement: Invoke garbage collection when reindex all pages by elasticsearch
 * Fix: MathJax rendering does not work
 * Fix: MathJax rendering does not work

+ 5 - 7
README.md

@@ -11,9 +11,6 @@
 <p align="center">
 <p align="center">
   <a href="https://docs.growi.org">Documentation</a> / <a href="https://demo.growi.org">Demo</a>
   <a href="https://docs.growi.org">Documentation</a> / <a href="https://demo.growi.org">Demo</a>
 </p>
 </p>
-<p align="center">
-  <a href="https://heroku.com/deploy"><img src="https://www.herokucdn.com/deploy/button.png"></a>
-</p>
 
 
 
 
 GROWI
 GROWI
@@ -33,8 +30,8 @@ Table Of Contents
 
 
 - [Features](#features)
 - [Features](#features)
 - [Quick Start for Production](#quick-start-for-production)
 - [Quick Start for Production](#quick-start-for-production)
-    - [Heroku](#heroku)
     - [docker-compose](#docker-compose)
     - [docker-compose](#docker-compose)
+    - [Helm (Experimental)](#helm)
     - [On-premise](#on-premise)
     - [On-premise](#on-premise)
 - [Environment Variables](#environment-variables)
 - [Environment Variables](#environment-variables)
 - [Documentation](#documentation)
 - [Documentation](#documentation)
@@ -61,14 +58,15 @@ Features
 Quick Start for Production
 Quick Start for Production
 ===========================
 ===========================
 
 
-### Heroku
-
-- [GROWI Docs: Launch on Heroku](https://docs.growi.org/en/admin-guide/getting-started/heroku.html) ([en](https://docs.growi.org/en/admin-guide/getting-started/heroku.html)/[ja](https://docs.growi.org/ja/admin-guide/getting-started/heroku.html))
 
 
 ### docker-compose
 ### docker-compose
 
 
 - [GROWI Docs: Launch with docker-compose](https://docs.growi.org/en/admin-guide/getting-started/docker-compose.html) ([en](https://docs.growi.org/en/admin-guide/getting-started/docker-compose.html)/[ja](https://docs.growi.org/ja/admin-guide/getting-started/docker-compose.html))
 - [GROWI Docs: Launch with docker-compose](https://docs.growi.org/en/admin-guide/getting-started/docker-compose.html) ([en](https://docs.growi.org/en/admin-guide/getting-started/docker-compose.html)/[ja](https://docs.growi.org/ja/admin-guide/getting-started/docker-compose.html))
 
 
+### Helm (Experimental)
+
+- [GROWI Helm Chart](https://github.com/weseek/helm-charts/tree/master/charts/growi)
+
 ### On-premise
 ### On-premise
 
 
 **[Migration Guide from Crowi](https://docs.growi.org/en/admin-guide/migration-guide/from-crowi-onpremise.html) ([en](https://docs.growi.org/en/admin-guide/migration-guide/from-crowi-onpremise.html)/[ja](https://docs.growi.org/ja/admin-guide/migration-guide/from-crowi-onpremise.html))** is here.
 **[Migration Guide from Crowi](https://docs.growi.org/en/admin-guide/migration-guide/from-crowi-onpremise.html) ([en](https://docs.growi.org/en/admin-guide/migration-guide/from-crowi-onpremise.html)/[ja](https://docs.growi.org/ja/admin-guide/migration-guide/from-crowi-onpremise.html))** is here.

+ 1 - 0
config/env.dev.js

@@ -21,4 +21,5 @@ module.exports = {
   // USER_UPPER_LIMIT: 0,
   // USER_UPPER_LIMIT: 0,
   // DEV_HTTPS: true,
   // DEV_HTTPS: true,
   // FORCE_WIKI_MODE: 'private', // 'public', 'private', undefined
   // FORCE_WIKI_MODE: 'private', // 'public', 'private', undefined
+  // PROMSTER_ENABLED: true,
 };
 };

+ 5 - 2
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "4.2.6-RC",
+  "version": "4.2.8-RC",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -75,12 +75,14 @@
     "@google-cloud/storage": "^3.3.0",
     "@google-cloud/storage": "^3.3.0",
     "@kobalab/socket.io-session": "^1.0.3",
     "@kobalab/socket.io-session": "^1.0.3",
     "@slack/bolt": "^3.0.0",
     "@slack/bolt": "^3.0.0",
+    "@promster/express": "^5.0.1",
+    "@promster/server": "^6.0.0",
     "JSONStream": "^1.3.5",
     "JSONStream": "^1.3.5",
     "archiver": "^3.1.1",
     "archiver": "^3.1.1",
     "array.prototype.flatmap": "^1.2.2",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
     "async-canvas-to-blob": "^1.0.3",
     "aws-sdk": "^2.88.0",
     "aws-sdk": "^2.88.0",
-    "axios": "^0.19.0",
+    "axios": "^0.21.1",
     "body-parser": "^1.18.2",
     "body-parser": "^1.18.2",
     "bunyan": "^1.8.12",
     "bunyan": "^1.8.12",
     "bunyan-format": "^0.2.1",
     "bunyan-format": "^0.2.1",
@@ -137,6 +139,7 @@
     "passport-local": "^1.0.0",
     "passport-local": "^1.0.0",
     "passport-saml": "^1.0.0",
     "passport-saml": "^1.0.0",
     "passport-twitter": "^1.0.4",
     "passport-twitter": "^1.0.4",
+    "prom-client": "^13.0.0",
     "react-card-flip": "^1.0.10",
     "react-card-flip": "^1.0.10",
     "react-image-crop": "^8.3.0",
     "react-image-crop": "^8.3.0",
     "reconnecting-websocket": "^4.4.0",
     "reconnecting-websocket": "^4.4.0",

+ 10 - 4
src/client/js/components/ContentLinkButtons.jsx

@@ -1,6 +1,8 @@
 import React, { useMemo } from 'react';
 import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
+import { isTopPage } from '@commons/util/path-utils';
+import AppContainer from '../services/AppContainer';
 import NavigationContainer from '../services/NavigationContainer';
 import NavigationContainer from '../services/NavigationContainer';
 import PageContainer from '../services/PageContainer';
 import PageContainer from '../services/PageContainer';
 
 
@@ -16,9 +18,12 @@ const WIKI_HEADER_LINK = 120;
  */
  */
 const ContentLinkButtons = (props) => {
 const ContentLinkButtons = (props) => {
 
 
-  const { navigationContainer, pageContainer } = props;
-  const { pageUser } = pageContainer.state;
+  const { appContainer, navigationContainer, pageContainer } = props;
+  const { pageUser, path } = pageContainer.state;
   const { isPageExist } = pageContainer.state;
   const { isPageExist } = pageContainer.state;
+  const { isSharedUser } = appContainer;
+
+  const isTopPagePath = isTopPage(path);
 
 
   // get element for smoothScroll
   // get element for smoothScroll
   const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
   const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
@@ -71,7 +76,7 @@ const ContentLinkButtons = (props) => {
 
 
   return (
   return (
     <>
     <>
-      {isPageExist && <CommentLinkButton />}
+      {isPageExist && !isSharedUser && !isTopPagePath && <CommentLinkButton />}
 
 
       <div className="mt-3 d-flex justify-content-between">
       <div className="mt-3 d-flex justify-content-between">
         {pageUser && <><BookMarkLinkButton /><RecentlyCreatedLinkButton /></>}
         {pageUser && <><BookMarkLinkButton /><RecentlyCreatedLinkButton /></>}
@@ -82,8 +87,9 @@ const ContentLinkButtons = (props) => {
 };
 };
 
 
 ContentLinkButtons.propTypes = {
 ContentLinkButtons.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 };
 
 
-export default withUnstatedContainers(ContentLinkButtons, [NavigationContainer, PageContainer]);
+export default withUnstatedContainers(ContentLinkButtons, [AppContainer, NavigationContainer, PageContainer]);

+ 5 - 3
src/client/js/components/EmptyTrashModal.jsx

@@ -8,12 +8,13 @@ import {
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
+import SocketIoContainer from '../services/SocketIoContainer';
 import AppContainer from '../services/AppContainer';
 import AppContainer from '../services/AppContainer';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 
 const EmptyTrashModal = (props) => {
 const EmptyTrashModal = (props) => {
   const {
   const {
-    t, isOpen, onClose, appContainer,
+    t, isOpen, onClose, appContainer, socketIoContainer,
   } = props;
   } = props;
 
 
   const [errs, setErrs] = useState(null);
   const [errs, setErrs] = useState(null);
@@ -22,7 +23,7 @@ const EmptyTrashModal = (props) => {
     setErrs(null);
     setErrs(null);
 
 
     try {
     try {
-      await appContainer.apiv3Delete('/pages/empty-trash');
+      await appContainer.apiv3Delete('/pages/empty-trash', { socketClientId: socketIoContainer.getSocketClientId() });
       window.location.reload();
       window.location.reload();
     }
     }
     catch (err) {
     catch (err) {
@@ -55,12 +56,13 @@ const EmptyTrashModal = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const EmptyTrashModalWrapper = withUnstatedContainers(EmptyTrashModal, [AppContainer]);
+const EmptyTrashModalWrapper = withUnstatedContainers(EmptyTrashModal, [AppContainer, SocketIoContainer]);
 
 
 
 
 EmptyTrashModal.propTypes = {
 EmptyTrashModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  socketIoContainer: PropTypes.instanceOf(SocketIoContainer),
 
 
   isOpen: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
   onClose: PropTypes.func.isRequired,

+ 23 - 2
src/client/js/components/InstallerForm.jsx

@@ -13,9 +13,12 @@ class InstallerForm extends React.Component {
 
 
     this.state = {
     this.state = {
       isValidUserName: true,
       isValidUserName: true,
+      isSubmittingDisabled: false,
       selectedLang: {},
       selectedLang: {},
     };
     };
     // this.checkUserName = this.checkUserName.bind(this);
     // this.checkUserName = this.checkUserName.bind(this);
+
+    this.submitHandler = this.submitHandler.bind(this);
   }
   }
 
 
   componentWillMount() {
   componentWillMount() {
@@ -39,6 +42,19 @@ class InstallerForm extends React.Component {
     this.setState({ selectedLang: meta });
     this.setState({ selectedLang: meta });
   }
   }
 
 
+  submitHandler() {
+    if (this.state.isSubmittingDisabled) {
+      return;
+    }
+
+    this.setState({ isSubmittingDisabled: true });
+    setTimeout(() => {
+      this.setState({ isSubmittingDisabled: false });
+    }, 3000);
+
+    document['register-form'].submit();
+  }
+
   render() {
   render() {
     const hasErrorClass = this.state.isValidUserName ? '' : ' has-error';
     const hasErrorClass = this.state.isValidUserName ? '' : ' has-error';
     const unavailableUserId = this.state.isValidUserName
     const unavailableUserId = this.state.isValidUserName
@@ -56,7 +72,7 @@ class InstallerForm extends React.Component {
           </div>
           </div>
         </div>
         </div>
         <div className="row">
         <div className="row">
-          <form role="form" action="/installer" method="post" id="register-form" className="col-md-12">
+          <form role="form" action="/installer" method="post" id="register-form" className="col-md-12" onSubmit={this.submitHandler}>
             <div className="dropdown mb-3">
             <div className="dropdown mb-3">
               <div className="d-flex dropdown-with-icon">
               <div className="d-flex dropdown-with-icon">
                 <i className="icon-bubbles border-0 rounded-0" />
                 <i className="icon-bubbles border-0 rounded-0" />
@@ -149,7 +165,12 @@ class InstallerForm extends React.Component {
             <input type="hidden" name="_csrf" value={this.props.csrf} />
             <input type="hidden" name="_csrf" value={this.props.csrf} />
 
 
             <div className="input-group mt-4 mb-3 d-flex justify-content-center">
             <div className="input-group mt-4 mb-3 d-flex justify-content-center">
-              <button type="submit" className="btn-fill btn btn-register" id="register">
+              <button
+                type="submit"
+                className="btn-fill btn btn-register"
+                id="register"
+                disabled={this.state.isSubmittingDisabled}
+              >
                 <div className="eff"></div>
                 <div className="eff"></div>
                 <span className="btn-label"><i className="icon-user-follow" /></span>
                 <span className="btn-label"><i className="icon-user-follow" /></span>
                 <span className="btn-label-text">{ this.props.t('Create') }</span>
                 <span className="btn-label-text">{ this.props.t('Create') }</span>

+ 6 - 5
src/client/js/components/Navbar/AuthorInfo.jsx

@@ -1,10 +1,11 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-
 import { userPageRoot } from '@commons/util/path-utils';
 import { userPageRoot } from '@commons/util/path-utils';
+import { format } from 'date-fns';
 
 
 import UserPicture from '../User/UserPicture';
 import UserPicture from '../User/UserPicture';
 
 
+const formatType = 'yyyy/MM/dd HH:mm';
 const AuthorInfo = (props) => {
 const AuthorInfo = (props) => {
   const {
   const {
     mode, user, date, locate,
     mode, user, date, locate,
@@ -14,14 +15,14 @@ const AuthorInfo = (props) => {
     ? 'Created by'
     ? 'Created by'
     : 'Updated by';
     : 'Updated by';
   const infoLabelForFooter = mode === 'create'
   const infoLabelForFooter = mode === 'create'
-    ? 'Last revision posted at'
-    : 'Created at';
+    ? 'Created at'
+    : 'Last revision posted at';
   const userLabel = user != null
   const userLabel = user != null
     ? <a href={userPageRoot(user)}>{user.name}</a>
     ? <a href={userPageRoot(user)}>{user.name}</a>
     : <i>Unknown</i>;
     : <i>Unknown</i>;
 
 
   if (locate === 'footer') {
   if (locate === 'footer') {
-    return <p>{infoLabelForFooter} {date} by <UserPicture user={user} size="sm" /> {userLabel}</p>;
+    return <p>{infoLabelForFooter} {format(new Date(date), formatType)} by <UserPicture user={user} size="sm" /> {userLabel}</p>;
   }
   }
 
 
   return (
   return (
@@ -31,7 +32,7 @@ const AuthorInfo = (props) => {
       </div>
       </div>
       <div>
       <div>
         <div>{infoLabelForSubNav} {userLabel}</div>
         <div>{infoLabelForSubNav} {userLabel}</div>
-        <div className="text-muted text-date">{date}</div>
+        <div className="text-muted text-date">{format(new Date(date), formatType)}</div>
       </div>
       </div>
     </div>
     </div>
   );
   );

+ 10 - 2
src/client/js/components/Page/TrashPageAlert.jsx

@@ -15,7 +15,7 @@ import PageDeleteModal from '../PageDeleteModal';
 const TrashPageAlert = (props) => {
 const TrashPageAlert = (props) => {
   const { t, pageContainer } = props;
   const { t, pageContainer } = props;
   const {
   const {
-    path, isDeleted, lastUpdateUsername, updatedAt, isAbleToDeleteCompletely,
+    path, isDeleted, lastUpdateUsername, updatedAt, deletedUserName, deletedAt, isAbleToDeleteCompletely,
   } = pageContainer.state;
   } = pageContainer.state;
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
   const [isPutbackPageModalShown, setIsPutbackPageModalShown] = useState(false);
   const [isPutbackPageModalShown, setIsPutbackPageModalShown] = useState(false);
@@ -110,7 +110,15 @@ const TrashPageAlert = (props) => {
       <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
       <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
         <div className="flex-grow-1">
         <div className="flex-grow-1">
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
-          {isDeleted && <span><br /><UserPicture user={{ username: lastUpdateUsername }} /> Deleted by {lastUpdateUsername} at {updatedAt}</span>}
+          {isDeleted && (
+            <>
+              <br />
+              <UserPicture user={{ username: deletedUserName || lastUpdateUsername }} />
+              <span className="ml-2">
+                Deleted by {deletedUserName || lastUpdateUsername} at {deletedAt || updatedAt}
+              </span>
+            </>
+          )}
         </div>
         </div>
         <div className="pt-1 d-flex align-items-end align-items-lg-center">
         <div className="pt-1 d-flex align-items-end align-items-lg-center">
           <span>{ pageContainer.isAbleToShowEmptyTrashButton && renderEmptyButton()}</span>
           <span>{ pageContainer.isAbleToShowEmptyTrashButton && renderEmptyButton()}</span>

+ 14 - 3
src/client/js/components/PageEditor/GridEditorUtil.js

@@ -31,6 +31,9 @@ class GridEditorUtil {
   isInGridBlock(editor) {
   isInGridBlock(editor) {
     const bog = this.getBog(editor);
     const bog = this.getBog(editor);
     const eog = this.getEog(editor);
     const eog = this.getEog(editor);
+    if (bog === null || eog === null) {
+      return false;
+    }
     return (JSON.stringify(bog) !== JSON.stringify(eog));
     return (JSON.stringify(bog) !== JSON.stringify(eog));
   }
   }
 
 
@@ -80,7 +83,7 @@ class GridEditorUtil {
     }
     }
 
 
     if (!isFound) {
     if (!isFound) {
-      return { line: curPos.line, ch: curPos.ch };
+      return null;
     }
     }
 
 
     const bodLine = Math.max(firstLine, line);
     const bodLine = Math.max(firstLine, line);
@@ -114,7 +117,7 @@ class GridEditorUtil {
     }
     }
 
 
     if (!isFound) {
     if (!isFound) {
-      return { line: curPos.line, ch: curPos.ch };
+      return null;
     }
     }
 
 
     const eodLine = Math.min(line, lastLine);
     const eodLine = Math.min(line, lastLine);
@@ -124,7 +127,15 @@ class GridEditorUtil {
 
 
   replaceGridWithHtmlWithEditor(editor, grid) {
   replaceGridWithHtmlWithEditor(editor, grid) {
     const curPos = editor.getCursor();
     const curPos = editor.getCursor();
-    editor.getDoc().replaceRange(grid.toString(), this.getBog(editor), this.getEog(editor));
+    let bog = this.getBog(editor);
+    let eog = this.getEog(editor);
+
+    if (bog === null || eog === null) {
+      bog = curPos;
+      eog = curPos;
+    }
+
+    editor.getDoc().replaceRange(grid.toString(), bog, eog);
     editor.getDoc().setCursor(curPos.line + 1, 2);
     editor.getDoc().setCursor(curPos.line + 1, 2);
   }
   }
 
 

+ 10 - 11
src/client/js/components/PageEditor/MarkdownDrawioUtil.js

@@ -10,7 +10,7 @@ class MarkdownDrawioUtil {
 
 
   /**
   /**
    * return the postion of the BOD(beginning of drawio)
    * return the postion of the BOD(beginning of drawio)
-   * (If the cursor is not in a drawio block, return its position)
+   * (If the BOD is not found after the cursor or the EOD is found before the BOD, return null)
    */
    */
   getBod(editor) {
   getBod(editor) {
     const curPos = editor.getCursor();
     const curPos = editor.getCursor();
@@ -36,7 +36,7 @@ class MarkdownDrawioUtil {
     }
     }
 
 
     if (!isFound) {
     if (!isFound) {
-      return { line: curPos.line, ch: curPos.ch };
+      return null;
     }
     }
 
 
     const bodLine = Math.max(firstLine, line);
     const bodLine = Math.max(firstLine, line);
@@ -45,7 +45,7 @@ class MarkdownDrawioUtil {
 
 
   /**
   /**
    * return the postion of the EOD(end of drawio)
    * return the postion of the EOD(end of drawio)
-   * (If the cursor is not in a drawio block, return its position)
+   * (If the EOD is not found after the cursor or the BOD is found before the EOD, return null)
    */
    */
   getEod(editor) {
   getEod(editor) {
     const curPos = editor.getCursor();
     const curPos = editor.getCursor();
@@ -71,7 +71,7 @@ class MarkdownDrawioUtil {
     }
     }
 
 
     if (!isFound) {
     if (!isFound) {
-      return { line: curPos.line, ch: curPos.ch };
+      return null;
     }
     }
 
 
     const eodLine = Math.min(line, lastLine);
     const eodLine = Math.min(line, lastLine);
@@ -85,17 +85,17 @@ class MarkdownDrawioUtil {
   isInDrawioBlock(editor) {
   isInDrawioBlock(editor) {
     const bod = this.getBod(editor);
     const bod = this.getBod(editor);
     const eod = this.getEod(editor);
     const eod = this.getEod(editor);
-
-    return (JSON.stringify(bod) !== JSON.stringify(eod));
+    if (bod === null || eod === null) {
+      return false;
+    }
+    return JSON.stringify(bod) !== JSON.stringify(eod);
   }
   }
 
 
   /**
   /**
    * return drawioData instance where the cursor is
    * return drawioData instance where the cursor is
-   * (If the cursor is not in a drawio block, return current line)
+   * (If the cursor is not in a drawio block, return null)
    */
    */
   getMarkdownDrawioMxfile(editor) {
   getMarkdownDrawioMxfile(editor) {
-    const curPos = editor.getCursor();
-
     if (this.isInDrawioBlock(editor)) {
     if (this.isInDrawioBlock(editor)) {
       const bod = this.getBod(editor);
       const bod = this.getBod(editor);
       const eod = this.getEod(editor);
       const eod = this.getEod(editor);
@@ -108,8 +108,7 @@ class MarkdownDrawioUtil {
 
 
       return editor.getDoc().getRange(bod, eod);
       return editor.getDoc().getRange(bod, eod);
     }
     }
-
-    return editor.getDoc().getLine(curPos.line);
+    return null;
   }
   }
 
 
   replaceFocusedDrawioWithEditor(editor, drawioData) {
   replaceFocusedDrawioWithEditor(editor, drawioData) {

+ 10 - 1
src/client/js/components/StaffCredit/Contributor.js

@@ -10,6 +10,7 @@ const contributors = [
           { position: 'Soncho 1st', name: 'mizozobu' },
           { position: 'Soncho 1st', name: 'mizozobu' },
           { position: 'Soncho 2nd', name: 'yusuketk' },
           { position: 'Soncho 2nd', name: 'yusuketk' },
           { position: 'Paladin', name: 'itizawa' },
           { position: 'Paladin', name: 'itizawa' },
+          { position: 'Valkyrie', name: 'kaoritokashiki' },
         ],
         ],
       },
       },
       {
       {
@@ -35,7 +36,12 @@ const contributors = [
           { name: 'ryuichi-e' },
           { name: 'ryuichi-e' },
           { name: 'N1koge' },
           { name: 'N1koge' },
           { name: 'Ertai87' },
           { name: 'Ertai87' },
-          { name: 'kaoritokashiki' },
+          { name: 'zahmis' },
+          { name: 'takeru0001' },
+          { name: 'Shu Katabe' },
+          { name: 'oshikishintaro' },
+          { name: 'makotoshiraishi' },
+          { name: 'yamagai' },
         ],
         ],
       },
       },
     ],
     ],
@@ -53,6 +59,7 @@ const contributors = [
           { name: 'hitochan777' },
           { name: 'hitochan777' },
           { name: 'ttaka66' },
           { name: 'ttaka66' },
           { name: 'watagashi' },
           { name: 'watagashi' },
+          { name: 'paichi81' },
           { name: 'nt-7' },
           { name: 'nt-7' },
           { name: 'hideo54' },
           { name: 'hideo54' },
           { name: 'wadahiro' },
           { name: 'wadahiro' },
@@ -79,6 +86,7 @@ const contributors = [
           { name: 'aximov' },
           { name: 'aximov' },
           { name: 'tats-u' },
           { name: 'tats-u' },
           { name: 'yamatomo717' },
           { name: 'yamatomo717' },
+          { name: 'tohutohu' },
         ],
         ],
       },
       },
     ],
     ],
@@ -94,6 +102,7 @@ const contributors = [
           { name: 'Kanta Nishitani' },
           { name: 'Kanta Nishitani' },
           { position: 'The University of Tokyo', name: 'Takashi Yoneuchi' },
           { position: 'The University of Tokyo', name: 'Takashi Yoneuchi' },
           { position: 'DeCurret', name: 'Yusuke Tanomogi' },
           { position: 'DeCurret', name: 'Yusuke Tanomogi' },
+          { position: 'Flatt Security', name: 'stypr' },
         ],
         ],
       },
       },
     ],
     ],

+ 14 - 0
src/client/js/services/AdminAppContainer.js

@@ -201,6 +201,20 @@ export default class AdminAppContainer extends Container {
     this.setState({ smtpPassword });
     this.setState({ smtpPassword });
   }
   }
 
 
+  /**
+   * Change sesAccessKeyId
+   */
+  changeSesAccessKeyId(sesAccessKeyId) {
+    this.setState({ sesAccessKeyId });
+  }
+
+  /**
+   * Change sesSecretAccessKey
+   */
+  changeSesSecretAccessKey(sesSecretAccessKey) {
+    this.setState({ sesSecretAccessKey });
+  }
+
   /**
   /**
    * Change s3Region
    * Change s3Region
    */
    */

+ 2 - 0
src/client/js/services/PageContainer.js

@@ -60,6 +60,7 @@ export default class PageContainer extends Container {
       sumOfBookmarks: 0,
       sumOfBookmarks: 0,
       createdAt: mainContent.getAttribute('data-page-created-at'),
       createdAt: mainContent.getAttribute('data-page-created-at'),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
+      deletedAt: mainContent.getAttribute('data-page-deleted-at') || null,
 
 
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isTrashPage: isTrashPage(path),
       isTrashPage: isTrashPage(path),
@@ -80,6 +81,7 @@ export default class PageContainer extends Container {
       remoteRevisionId: revisionId,
       remoteRevisionId: revisionId,
       revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null,
       revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null,
       lastUpdateUsername: mainContent.getAttribute('data-page-last-update-username') || null,
       lastUpdateUsername: mainContent.getAttribute('data-page-last-update-username') || null,
+      deleteUsername: mainContent.getAttribute('data-page-delete-username') || null,
       pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
       pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
       hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
       hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
       isHackmdDraftUpdatingInRealtime: false,
       isHackmdDraftUpdatingInRealtime: false,

+ 2 - 0
src/server/crowi/express-init.js

@@ -18,6 +18,7 @@ module.exports = function(crowi, app) {
   const i18nSprintf = require('i18next-sprintf-postprocessor');
   const i18nSprintf = require('i18next-sprintf-postprocessor');
   const i18nMiddleware = require('i18next-express-middleware');
   const i18nMiddleware = require('i18next-express-middleware');
 
 
+  const promster = require('../middlewares/promster')(crowi, app);
   const registerSafeRedirect = require('../middlewares/safe-redirect')();
   const registerSafeRedirect = require('../middlewares/safe-redirect')();
   const injectCurrentuserToLocalvars = require('../middlewares/inject-currentuser-to-localvars')();
   const injectCurrentuserToLocalvars = require('../middlewares/inject-currentuser-to-localvars')();
   const autoReconnectToS2sMsgServer = require('../middlewares/auto-reconnect-to-s2s-msg-server')(crowi);
   const autoReconnectToS2sMsgServer = require('../middlewares/auto-reconnect-to-s2s-msg-server')(crowi);
@@ -116,6 +117,7 @@ module.exports = function(crowi, app) {
 
 
   app.use(flash());
   app.use(flash());
 
 
+  app.use(promster);
   app.use(registerSafeRedirect);
   app.use(registerSafeRedirect);
   app.use(injectCurrentuserToLocalvars);
   app.use(injectCurrentuserToLocalvars);
   app.use(autoReconnectToS2sMsgServer);
   app.use(autoReconnectToS2sMsgServer);

+ 12 - 7
src/server/crowi/index.js

@@ -149,13 +149,14 @@ Crowi.prototype.initForTest = async function() {
     // this.setupSlack(),
     // this.setupSlack(),
     // this.setupCsrf(),
     // this.setupCsrf(),
     // this.setUpFileUpload(),
     // this.setUpFileUpload(),
-    // this.setupAttachmentService(),
+    this.setupAttachmentService(),
     this.setUpAcl(),
     this.setUpAcl(),
     // this.setUpCustomize(),
     // this.setUpCustomize(),
     // this.setUpRestQiitaAPI(),
     // this.setUpRestQiitaAPI(),
     // this.setupUserGroup(),
     // this.setupUserGroup(),
     // this.setupExport(),
     // this.setupExport(),
     // this.setupImport(),
     // this.setupImport(),
+    this.setupPageService(),
   ]);
   ]);
 
 
   // globalNotification depends on slack and mailer
   // globalNotification depends on slack and mailer
@@ -406,7 +407,7 @@ Crowi.prototype.start = async function() {
   await this.init();
   await this.init();
   await this.buildServer();
   await this.buildServer();
 
 
-  const { express } = this;
+  const { express, configManager } = this;
 
 
   // setup plugins
   // setup plugins
   this.pluginService = new PluginService(this, express);
   this.pluginService = new PluginService(this, express);
@@ -421,6 +422,15 @@ Crowi.prototype.start = async function() {
       this.crowiDev.setupExpressAfterListening(express);
       this.crowiDev.setupExpressAfterListening(express);
     }
     }
   });
   });
+  // listen for promster
+  if (configManager.getConfig('crowi', 'promster:isEnabled')) {
+    const { createServer } = require('@promster/server');
+    const promsterPort = configManager.getConfig('crowi', 'promster:port');
+
+    createServer({ port: promsterPort }).then(() => {
+      logger.info(`[${this.node_env}] Promster server is listening on port ${promsterPort}`);
+    });
+  }
 
 
   this.socketIoService.attachServer(serverListening);
   this.socketIoService.attachServer(serverListening);
 
 
@@ -462,11 +472,6 @@ Crowi.prototype.setupRoutesAtLast = function() {
   require('../routes')(this, this.express);
   require('../routes')(this, this.express);
 };
 };
 
 
-Crowi.prototype.setupAfterInstall = function() {
-  this.pluginService.autoDetectAndLoadPlugins();
-  this.setupRoutesAtLast();
-};
-
 /**
 /**
  * require API for plugins
  * require API for plugins
  *
  *

+ 3 - 0
src/server/events/page.js

@@ -15,5 +15,8 @@ PageEvent.prototype.onCreate = function(page, user) {
 PageEvent.prototype.onUpdate = function(page, user) {
 PageEvent.prototype.onUpdate = function(page, user) {
   debug('onUpdate event fired');
   debug('onUpdate event fired');
 };
 };
+PageEvent.prototype.onCreateMany = function(pages, user) {
+  debug('onCreateMany event fired');
+};
 
 
 module.exports = PageEvent;
 module.exports = PageEvent;

+ 12 - 4
src/server/middlewares/application-installed.js

@@ -2,12 +2,20 @@ module.exports = (crowi) => {
   const { appService } = crowi;
   const { appService } = crowi;
 
 
   return async(req, res, next) => {
   return async(req, res, next) => {
-    const isInstalled = await appService.isDBInitialized();
+    const isDBInitialized = await appService.isDBInitialized();
 
 
-    if (!isInstalled) {
-      return res.redirect('/installer');
+    // when already installed
+    if (isDBInitialized) {
+      return next();
     }
     }
 
 
-    return next();
+    // when other server have initialized DB
+    const isDBInitializedAfterForceReload = await appService.isDBInitialized(true);
+    if (isDBInitializedAfterForceReload) {
+      await appService.setupAfterInstall();
+      return res.safeRedirect(req.originalUrl);
+    }
+
+    return res.redirect('/installer');
   };
   };
 };
 };

+ 2 - 2
src/server/middlewares/application-not-installed.js

@@ -2,9 +2,9 @@ module.exports = (crowi) => {
   const { appService } = crowi;
   const { appService } = crowi;
 
 
   return async(req, res, next) => {
   return async(req, res, next) => {
-    const isInstalled = await appService.isDBInitialized();
+    const isDBInitialized = await appService.isDBInitialized(true);
 
 
-    if (isInstalled) {
+    if (isDBInitialized) {
       req.flash('errorMessage', req.t('message.application_already_installed'));
       req.flash('errorMessage', req.t('message.application_already_installed'));
       return res.redirect('admin');
       return res.redirect('admin');
     }
     }

+ 11 - 0
src/server/middlewares/promster.js

@@ -0,0 +1,11 @@
+module.exports = (crowi, app) => {
+  const { configManager } = crowi;
+
+  // when disabled
+  if (!configManager.getConfig('crowi', 'promster:isEnabled')) {
+    return (req, res, next) => next();
+  }
+
+  const { createMiddleware } = require('@promster/express');
+  return createMiddleware({ app });
+};

+ 8 - 204
src/server/models/page.js

@@ -14,7 +14,7 @@ const differenceInYears = require('date-fns/differenceInYears');
 
 
 const { pathUtils } = require('growi-commons');
 const { pathUtils } = require('growi-commons');
 const templateChecker = require('@commons/util/template-checker');
 const templateChecker = require('@commons/util/template-checker');
-const { isTopPage } = require('@commons/util/path-utils');
+const { isTopPage, isTrashPage } = require('@commons/util/path-utils');
 const escapeStringRegexp = require('escape-string-regexp');
 const escapeStringRegexp = require('escape-string-regexp');
 
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
@@ -66,6 +66,8 @@ const pageSchema = new mongoose.Schema({
   hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
   hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
   createdAt: { type: Date, default: Date.now },
   createdAt: { type: Date, default: Date.now },
   updatedAt: { type: Date, default: Date.now },
   updatedAt: { type: Date, default: Date.now },
+  deleteUser: { type: ObjectId, ref: 'User' },
+  deletedAt: { type: Date },
 }, {
 }, {
   toJSON: { getters: true },
   toJSON: { getters: true },
   toObject: { getters: true },
   toObject: { getters: true },
@@ -107,6 +109,7 @@ const populateDataToShowRevision = (page, userPublicFields) => {
     .populate([
     .populate([
       { path: 'lastUpdateUser', model: 'User', select: userPublicFields },
       { path: 'lastUpdateUser', model: 'User', select: userPublicFields },
       { path: 'creator', model: 'User', select: userPublicFields },
       { path: 'creator', model: 'User', select: userPublicFields },
+      { path: 'deleteUser', model: 'User', select: userPublicFields },
       { path: 'grantedGroup', model: 'UserGroup' },
       { path: 'grantedGroup', model: 'UserGroup' },
       { path: 'revision', model: 'Revision', populate: {
       { path: 'revision', model: 'Revision', populate: {
         path: 'author', model: 'User', select: userPublicFields,
         path: 'author', model: 'User', select: userPublicFields,
@@ -293,6 +296,7 @@ module.exports = function(crowi) {
     pageEvent = crowi.event('page');
     pageEvent = crowi.event('page');
     pageEvent.on('create', pageEvent.onCreate);
     pageEvent.on('create', pageEvent.onCreate);
     pageEvent.on('update', pageEvent.onUpdate);
     pageEvent.on('update', pageEvent.onUpdate);
+    pageEvent.on('createMany', pageEvent.onCreateMany);
   }
   }
 
 
   function validateCrowi() {
   function validateCrowi() {
@@ -302,7 +306,7 @@ module.exports = function(crowi) {
   }
   }
 
 
   pageSchema.methods.isDeleted = function() {
   pageSchema.methods.isDeleted = function() {
-    return (this.status === STATUS_DELETED) || checkIfTrashed(this.path);
+    return (this.status === STATUS_DELETED) || isTrashPage(this.path);
   };
   };
 
 
   pageSchema.methods.isPublic = function() {
   pageSchema.methods.isPublic = function() {
@@ -1102,121 +1106,6 @@ module.exports = function(crowi) {
     }
     }
   };
   };
 
 
-  pageSchema.statics.deletePage = async function(pageData, user, options = {}) {
-    const newPath = this.getDeletedPageName(pageData.path);
-    const isTrashed = checkIfTrashed(pageData.path);
-
-    if (isTrashed) {
-      throw new Error('This method does NOT support deleting trashed pages.');
-    }
-
-    const socketClientId = options.socketClientId || null;
-    if (this.isDeletableName(pageData.path)) {
-
-      pageData.status = STATUS_DELETED;
-      const updatedPageData = await this.rename(pageData, newPath, user, { socketClientId, createRedirectPage: true });
-
-      return updatedPageData;
-    }
-
-    return Promise.reject(new Error('Page is not deletable.'));
-  };
-
-  const checkIfTrashed = (path) => {
-    return (path.search(/^\/trash/) !== -1);
-  };
-
-  pageSchema.statics.deletePageRecursively = async function(targetPage, user, options = {}) {
-    const isTrashed = checkIfTrashed(targetPage.path);
-
-    if (isTrashed) {
-      throw new Error('This method does NOT supports deleting trashed pages.');
-    }
-
-    // find manageable descendants (this array does not include GRANT_RESTRICTED)
-    const pages = await this.findManageableListWithDescendants(targetPage, user, options);
-
-    await Promise.all(pages.map((page) => {
-      return this.deletePage(page, user, options);
-    }));
-  };
-
-  // TODO: transplant to service/page.js because page deletion affects various models data
-  pageSchema.statics.revertDeletedPage = async function(page, user, options = {}) {
-    const newPath = this.getRevertDeletedPageName(page.path);
-
-    const originPage = await this.findByPath(newPath);
-    if (originPage != null) {
-      // 削除時、元ページの path には必ず redirectTo 付きで、ページが作成される。
-      // そのため、そいつは削除してOK
-      // が、redirectTo ではないページが存在している場合それは何かがおかしい。(データ補正が必要)
-      if (originPage.redirectTo !== page.path) {
-        throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
-      }
-
-      await this.completelyDeletePage(originPage, options);
-    }
-
-    page.status = STATUS_PUBLISHED;
-    page.lastUpdateUser = user;
-    debug('Revert deleted the page', page, newPath);
-    const updatedPage = await this.rename(page, newPath, user, {});
-
-    return updatedPage;
-  };
-
-  pageSchema.statics.revertDeletedPageRecursively = async function(targetPage, user, options = {}) {
-    const findOpts = { includeTrashed: true };
-    const pages = await this.findManageableListWithDescendants(targetPage, user, findOpts);
-
-    let updatedPage = null;
-    await Promise.all(pages.map((page) => {
-      const isParent = (page.path === targetPage.path);
-      const p = this.revertDeletedPage(page, user, options);
-      if (isParent) {
-        updatedPage = p;
-      }
-      return p;
-    }));
-
-    return updatedPage;
-  };
-
-  /**
-   * This is danger.
-   */
-  // TODO: transplant to service/page.js because page deletion affects various models data
-  pageSchema.statics.completelyDeletePage = async function(pageData, user, options = {}) {
-    validateCrowi();
-
-    const { _id, path } = pageData;
-    const socketClientId = options.socketClientId || null;
-
-    logger.debug('Deleting completely', path);
-
-    await crowi.pageService.deleteCompletely(_id, path);
-
-    if (socketClientId != null) {
-      pageEvent.emit('delete', pageData, user, socketClientId); // update as renamed page
-    }
-    return pageData;
-  };
-
-  /**
-   * Delete Bookmarks, Attachments, Revisions, Pages and emit delete
-   */
-  // TODO: transplant to service/page.js because page deletion affects various models data
-  pageSchema.statics.completelyDeletePageRecursively = async function(targetPage, user, options = {}) {
-    const findOpts = { includeTrashed: true };
-
-    // find manageable descendants (this array does not include GRANT_RESTRICTED)
-    const pages = await this.findManageableListWithDescendants(targetPage, user, findOpts);
-
-    await Promise.all(pages.map((page) => {
-      return this.completelyDeletePage(page, user, options);
-    }));
-  };
-
   pageSchema.statics.removeByPath = function(path) {
   pageSchema.statics.removeByPath = function(path) {
     if (path == null) {
     if (path == null) {
       throw new Error('path is required');
       throw new Error('path is required');
@@ -1247,66 +1136,6 @@ module.exports = function(crowi) {
     await this.removeRedirectOriginPageByPath(redirectPage.path);
     await this.removeRedirectOriginPageByPath(redirectPage.path);
   };
   };
 
 
-  pageSchema.statics.rename = async function(pageData, newPagePath, user, options) {
-    validateCrowi();
-
-    const Page = this;
-    const Revision = crowi.model('Revision');
-    const path = pageData.path;
-    const createRedirectPage = options.createRedirectPage || false;
-    const updateMetadata = options.updateMetadata || false;
-    const socketClientId = options.socketClientId || null;
-
-    // sanitize path
-    newPagePath = crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
-
-    // update Page
-    pageData.path = newPagePath;
-    if (updateMetadata) {
-      pageData.lastUpdateUser = user;
-      pageData.updatedAt = Date.now();
-    }
-    const updatedPageData = await pageData.save();
-
-    // update Rivisions
-    await Revision.updateRevisionListByPath(path, { path: newPagePath }, {});
-
-    if (createRedirectPage) {
-      const body = `redirect ${newPagePath}`;
-      await Page.create(path, body, user, { redirectTo: newPagePath });
-    }
-
-    pageEvent.emit('delete', pageData, user, socketClientId);
-    pageEvent.emit('create', updatedPageData, user, socketClientId);
-
-    return updatedPageData;
-  };
-
-  pageSchema.statics.renameRecursively = async function(targetPage, newPagePathPrefix, user, options) {
-    validateCrowi();
-
-    const path = targetPage.path;
-    const pathRegExp = new RegExp(`^${escapeStringRegexp(path)}`, 'i');
-
-    // sanitize path
-    newPagePathPrefix = crowi.xss.process(newPagePathPrefix); // eslint-disable-line no-param-reassign
-
-    // find manageable descendants
-    const pages = await this.findManageableListWithDescendants(targetPage, user, options);
-
-    // TODO GW-4634 use stream
-    const promise = pages.map((page) => {
-      const newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
-      return this.rename(page, newPagePath, user, options);
-    });
-
-    await Promise.allSettled(promise);
-
-    targetPage.path = newPagePathPrefix;
-    return targetPage;
-
-  };
-
   pageSchema.statics.findListByPathsArray = async function(paths) {
   pageSchema.statics.findListByPathsArray = async function(paths) {
     const queryBuilder = new PageQueryBuilder(this.find());
     const queryBuilder = new PageQueryBuilder(this.find());
     queryBuilder.addConditionToListByPathsArray(paths);
     queryBuilder.addConditionToListByPathsArray(paths);
@@ -1314,33 +1143,6 @@ module.exports = function(crowi) {
     return await queryBuilder.query.exec();
     return await queryBuilder.query.exec();
   };
   };
 
 
-  // TODO: transplant to service/page.js because page deletion affects various models data
-  pageSchema.statics.handlePrivatePagesForDeletedGroup = async function(deletedGroup, action, transferToUserGroupId) {
-    const Page = mongoose.model('Page');
-
-    const pages = await this.find({ grantedGroup: deletedGroup });
-
-    switch (action) {
-      case 'public':
-        await Promise.all(pages.map((page) => {
-          return Page.publicizePage(page);
-        }));
-        break;
-      case 'delete':
-        await Promise.all(pages.map((page) => {
-          return Page.completelyDeletePage(page);
-        }));
-        break;
-      case 'transfer':
-        await Promise.all(pages.map((page) => {
-          return Page.transferPageToGroup(page, transferToUserGroupId);
-        }));
-        break;
-      default:
-        throw new Error('Unknown action for private pages');
-    }
-  };
-
   pageSchema.statics.publicizePage = async function(page) {
   pageSchema.statics.publicizePage = async function(page) {
     page.grantedGroup = null;
     page.grantedGroup = null;
     page.grant = GRANT_PUBLIC;
     page.grant = GRANT_PUBLIC;
@@ -1409,6 +1211,8 @@ module.exports = function(crowi) {
 
 
   };
   };
 
 
+  pageSchema.statics.STATUS_PUBLISHED = STATUS_PUBLISHED;
+  pageSchema.statics.STATUS_DELETED = STATUS_DELETED;
   pageSchema.statics.GRANT_PUBLIC = GRANT_PUBLIC;
   pageSchema.statics.GRANT_PUBLIC = GRANT_PUBLIC;
   pageSchema.statics.GRANT_RESTRICTED = GRANT_RESTRICTED;
   pageSchema.statics.GRANT_RESTRICTED = GRANT_RESTRICTED;
   pageSchema.statics.GRANT_SPECIFIED = GRANT_SPECIFIED;
   pageSchema.statics.GRANT_SPECIFIED = GRANT_SPECIFIED;

+ 1 - 2
src/server/models/user-group.js

@@ -92,7 +92,6 @@ class UserGroup {
   // グループの完全削除
   // グループの完全削除
   static async removeCompletelyById(deleteGroupId, action, transferToUserGroupId) {
   static async removeCompletelyById(deleteGroupId, action, transferToUserGroupId) {
     const UserGroupRelation = mongoose.model('UserGroupRelation');
     const UserGroupRelation = mongoose.model('UserGroupRelation');
-    const Page = mongoose.model('Page');
 
 
     const groupToDelete = await this.findById(deleteGroupId);
     const groupToDelete = await this.findById(deleteGroupId);
     if (groupToDelete == null) {
     if (groupToDelete == null) {
@@ -102,7 +101,7 @@ class UserGroup {
 
 
     await Promise.all([
     await Promise.all([
       UserGroupRelation.removeAllByUserGroup(deletedGroup),
       UserGroupRelation.removeAllByUserGroup(deletedGroup),
-      Page.handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId),
+      UserGroup.crowi.pageService.handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId),
     ]);
     ]);
 
 
     return deletedGroup;
     return deletedGroup;

+ 11 - 20
src/server/routes/apiv3/pages.js

@@ -4,7 +4,7 @@ const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line
 const express = require('express');
 const express = require('express');
 const pathUtils = require('growi-commons').pathUtils;
 const pathUtils = require('growi-commons').pathUtils;
 
 
-const { body } = require('express-validator/check');
+const { body } = require('express-validator');
 const { query } = require('express-validator');
 const { query } = require('express-validator');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
@@ -395,13 +395,7 @@ module.exports = (crowi) => {
       if (!page.isUpdatable(revisionId)) {
       if (!page.isUpdatable(revisionId)) {
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
       }
       }
-
-      if (isRecursively) {
-        page = await Page.renameRecursively(page, newPagePath, req.user, options);
-      }
-      else {
-        page = await Page.rename(page, newPagePath, req.user, options);
-      }
+      page = await crowi.pageService.renamePage(page, newPagePath, req.user, options, isRecursively);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
@@ -423,7 +417,9 @@ module.exports = (crowi) => {
     return res.apiv3(result);
     return res.apiv3(result);
   });
   });
 
 
-
+  validator.emptyTrash = [
+    query('socketClientId').if(value => value != null).isInt().withMessage('socketClientId must be int'),
+  ];
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -435,9 +431,12 @@ module.exports = (crowi) => {
    *          200:
    *          200:
    *            description: Succeeded to remove all trash pages
    *            description: Succeeded to remove all trash pages
    */
    */
-  router.delete('/empty-trash', loginRequired, adminRequired, csrf, async(req, res) => {
+  router.delete('/empty-trash', accessTokenParser, loginRequired, adminRequired, csrf, validator.emptyTrash, apiV3FormValidator, async(req, res) => {
+    const socketClientId = parseInt(req.query.socketClientId);
+    const options = { socketClientId };
+
     try {
     try {
-      const pages = await Page.completelyDeletePageRecursively({ path: '/trash' }, req.user);
+      const pages = await crowi.pageService.deletePageRecursivelyCompletely({ path: '/trash' }, req.user, options);
       return res.apiv3({ pages });
       return res.apiv3({ pages });
     }
     }
     catch (err) {
     catch (err) {
@@ -537,15 +536,7 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('Not Founded the page', 'notfound_or_forbidden'), 404);
       return res.apiv3Err(new ErrorV3('Not Founded the page', 'notfound_or_forbidden'), 404);
     }
     }
 
 
-    let newParentPage;
-
-    if (isRecursively) {
-      newParentPage = await crowi.pageService.duplicateRecursively(page, newPagePath, req.user);
-    }
-    else {
-      newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user);
-    }
-
+    const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively);
     const result = { page: serializePageSecurely(newParentPage) };
     const result = { page: serializePageSecurely(newParentPage) };
 
 
     page.path = newPagePath;
     page.path = newPagePath;

+ 1 - 11
src/server/routes/attachment.js

@@ -244,16 +244,6 @@ module.exports = function(crowi, app) {
     }
     }
   }
   }
 
 
-  async function removeAttachment(attachmentId) {
-    const { fileUploadService } = crowi;
-
-    // retrieve data from DB to get a completely populated instance
-    const attachment = await Attachment.findById(attachmentId);
-
-    await fileUploadService.deleteFile(attachment);
-
-    return attachment.remove();
-  }
 
 
   const actions = {};
   const actions = {};
   const api = {};
   const api = {};
@@ -637,7 +627,7 @@ module.exports = function(crowi, app) {
     }
     }
 
 
     try {
     try {
-      await removeAttachment(attachment);
+      await attachmentService.removeAttachment(attachment);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);

+ 2 - 2
src/server/routes/hackmd.js

@@ -191,8 +191,8 @@ module.exports = function(crowi, app) {
 
 
     const { status, headers } = hackmdResponse;
     const { status, headers } = hackmdResponse;
 
 
-    // validate HackMD/CodiMD specific header
-    if (headers['codimd-version'] == null && headers['hackmd-version'] == null) {
+    // validate HackMD/CodiMD/HedgeDoc specific header
+    if (headers['codimd-version'] == null && headers['hackmd-version'] == null && headers['hedgedoc-version'] == null) {
       const message = 'Connecting to a non-HackMD server.';
       const message = 'Connecting to a non-HackMD server.';
       logger.error(message);
       logger.error(message);
       return res.json(ApiResponse.error(message));
       return res.json(ApiResponse.error(message));

+ 34 - 33
src/server/routes/index.js

@@ -33,37 +33,33 @@ module.exports = function(crowi, app) {
 
 
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
 
 
-  app.get('/'                        , applicationInstalled, loginRequired , autoReconnectToSearch, page.showTopPage);
-
   // API v3
   // API v3
   app.use('/api-docs', require('./apiv3/docs')(crowi));
   app.use('/api-docs', require('./apiv3/docs')(crowi));
   app.use('/_api/v3', require('./apiv3')(crowi));
   app.use('/_api/v3', require('./apiv3')(crowi));
 
 
+  app.get('/'                         , applicationInstalled, loginRequired , autoReconnectToSearch, page.showTopPage);
+
+  app.get('/login/error/:reason'      , applicationInstalled, login.error);
+  app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
+  app.get('/login/invited'            , applicationInstalled, login.invited);
+  app.post('/login/activateInvited'   , applicationInstalled, form.invited                         , csrf, login.invited);
+  app.post('/login'                   , applicationInstalled, form.login                           , csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
+
+  app.post('/register'                , applicationInstalled, form.register                        , csrf, login.register);
+  app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
+  app.get('/logout'                   , applicationInstalled, logout.logout);
+
+  app.get('/admin'                    , applicationInstalled, loginRequiredStrictly , adminRequired , admin.index);
+  app.get('/admin/app'                , applicationInstalled, loginRequiredStrictly , adminRequired , admin.app.index);
+
   // installer
   // installer
   if (!isInstalled) {
   if (!isInstalled) {
     const installer = require('./installer')(crowi);
     const installer = require('./installer')(crowi);
-    app.get('/installer'               , applicationNotInstalled , installer.index);
-    app.post('/installer'              , applicationNotInstalled , form.register , csrf, installer.install);
+    app.get('/installer'              , applicationNotInstalled , installer.index);
+    app.post('/installer'             , applicationNotInstalled , form.register , csrf, installer.install);
     return;
     return;
   }
   }
 
 
-  app.get('/login/error/:reason'     , login.error);
-  app.get('/login'                   , applicationInstalled     , login.preLogin, login.login);
-  app.get('/login/invited'           , login.invited);
-  app.post('/login/activateInvited'  , form.invited                         , csrf, login.invited);
-  app.post('/login'                  , form.login                           , csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
-  app.post('/_api/login/testLdap'    , loginRequiredStrictly , form.login , loginPassport.testLdapCredentials);
-
-  app.post('/register'               , form.register                        , csrf, login.register);
-  app.get('/register'                , applicationInstalled     , login.preLogin, login.register);
-  app.get('/logout'                  , logout.logout);
-
-  app.get('/admin'                          , loginRequiredStrictly , adminRequired , admin.index);
-  app.get('/admin/app'                      , loginRequiredStrictly , adminRequired , admin.app.index);
-
-  // security admin
-  app.get('/admin/security'                     , loginRequiredStrictly , adminRequired , admin.security.index);
-
   // OAuth
   // OAuth
   app.get('/passport/google'                      , loginPassport.loginWithGoogle, loginPassport.loginFailure);
   app.get('/passport/google'                      , loginPassport.loginWithGoogle, loginPassport.loginFailure);
   app.get('/passport/github'                      , loginPassport.loginWithGitHub, loginPassport.loginFailure);
   app.get('/passport/github'                      , loginPassport.loginWithGitHub, loginPassport.loginFailure);
@@ -77,29 +73,34 @@ module.exports = function(crowi, app) {
   app.get('/passport/oidc/callback'               , loginPassport.loginPassportOidcCallback     , loginPassport.loginFailure);
   app.get('/passport/oidc/callback'               , loginPassport.loginPassportOidcCallback     , loginPassport.loginFailure);
   app.post('/passport/saml/callback'              , loginPassport.loginPassportSamlCallback     , loginPassport.loginFailure);
   app.post('/passport/saml/callback'              , loginPassport.loginPassportSamlCallback     , loginPassport.loginFailure);
 
 
+  app.post('/_api/login/testLdap'    , loginRequiredStrictly , form.login , loginPassport.testLdapCredentials);
+
+  // security admin
+  app.get('/admin/security'          , loginRequiredStrictly , adminRequired , admin.security.index);
+
   // markdown admin
   // markdown admin
-  app.get('/admin/markdown'                   , loginRequiredStrictly , adminRequired , admin.markdown.index);
+  app.get('/admin/markdown'          , loginRequiredStrictly , adminRequired , admin.markdown.index);
 
 
   // customize admin
   // customize admin
-  app.get('/admin/customize'                , loginRequiredStrictly , adminRequired , admin.customize.index);
+  app.get('/admin/customize'         , loginRequiredStrictly , adminRequired , admin.customize.index);
 
 
   // search admin
   // search admin
-  app.get('/admin/search'              , loginRequiredStrictly , adminRequired , admin.search.index);
+  app.get('/admin/search'            , loginRequiredStrictly , adminRequired , admin.search.index);
 
 
   // notification admin
   // notification admin
-  app.get('/admin/notification'              , loginRequiredStrictly , adminRequired , admin.notification.index);
-  app.get('/admin/notification/slackAuth'    , loginRequiredStrictly , adminRequired , admin.notification.slackAuth);
-  app.get('/admin/notification/slackSetting/disconnect', loginRequiredStrictly , adminRequired , admin.notification.disconnectFromSlack);
-  app.get('/admin/global-notification/new'   , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
-  app.get('/admin/global-notification/:id'   , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
+  app.get('/admin/notification'                         , loginRequiredStrictly , adminRequired , admin.notification.index);
+  app.get('/admin/notification/slackAuth'               , loginRequiredStrictly , adminRequired , admin.notification.slackAuth);
+  app.get('/admin/notification/slackSetting/disconnect' , loginRequiredStrictly , adminRequired , admin.notification.disconnectFromSlack);
+  app.get('/admin/global-notification/new'              , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
+  app.get('/admin/global-notification/:id'              , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
 
 
-  app.get('/admin/users'                , loginRequiredStrictly , adminRequired , admin.user.index);
+  app.get('/admin/users'                                , loginRequiredStrictly , adminRequired , admin.user.index);
 
 
-  app.get('/admin/users/external-accounts'               , loginRequiredStrictly , adminRequired , admin.externalAccount.index);
+  app.get('/admin/users/external-accounts'              , loginRequiredStrictly , adminRequired , admin.externalAccount.index);
 
 
   // user-groups admin
   // user-groups admin
-  app.get('/admin/user-groups'             , loginRequiredStrictly, adminRequired, admin.userGroup.index);
-  app.get('/admin/user-group-detail/:id'   , loginRequiredStrictly, adminRequired, admin.userGroup.detail);
+  app.get('/admin/user-groups'                          , loginRequiredStrictly, adminRequired, admin.userGroup.index);
+  app.get('/admin/user-group-detail/:id'                , loginRequiredStrictly, adminRequired, admin.userGroup.detail);
 
 
   // importer management for admin
   // importer management for admin
   app.get('/admin/importer'                     , loginRequiredStrictly , adminRequired , admin.importer.index);
   app.get('/admin/importer'                     , loginRequiredStrictly , adminRequired , admin.importer.index);

+ 1 - 1
src/server/routes/installer.js

@@ -85,7 +85,7 @@ module.exports = function(crowi) {
     // create initial pages
     // create initial pages
     await createInitialPages(adminUser, language);
     await createInitialPages(adminUser, language);
 
 
-    crowi.setupAfterInstall();
+    appService.setupAfterInstall();
     appService.publishPostInstallationMessage();
     appService.publishPostInstallationMessage();
 
 
     // login with passport
     // login with passport

+ 7 - 25
src/server/routes/page.js

@@ -238,6 +238,9 @@ module.exports = function(crowi, app) {
     if (page.revision.author != null) {
     if (page.revision.author != null) {
       renderVars.revision.author = renderVars.revision.author.toObject();
       renderVars.revision.author = renderVars.revision.author.toObject();
     }
     }
+    if (page.deleteUser != null) {
+      renderVars.page.deleteUser = renderVars.page.deleteUser.toObject();
+    }
   }
   }
 
 
   function addRenderVarsForPresentation(renderVars, page) {
   function addRenderVarsForPresentation(renderVars, page) {
@@ -1187,24 +1190,14 @@ module.exports = function(crowi, app) {
         if (!req.user.canDeleteCompletely(page.creator)) {
         if (!req.user.canDeleteCompletely(page.creator)) {
           return res.json(ApiResponse.error('You can not delete completely', 'user_not_admin'));
           return res.json(ApiResponse.error('You can not delete completely', 'user_not_admin'));
         }
         }
-        if (isRecursively) {
-          await Page.completelyDeletePageRecursively(page, req.user, options);
-        }
-        else {
-          await Page.completelyDeletePage(page, req.user, options);
-        }
+        await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);
       }
       }
       else {
       else {
         if (!page.isUpdatable(previousRevision)) {
         if (!page.isUpdatable(previousRevision)) {
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
         }
         }
 
 
-        if (isRecursively) {
-          await Page.deletePageRecursively(page, req.user, options);
-        }
-        else {
-          await Page.deletePage(page, req.user, options);
-        }
+        await crowi.pageService.deletePage(page, req.user, options, isRecursively);
       }
       }
     }
     }
     catch (err) {
     catch (err) {
@@ -1247,13 +1240,7 @@ module.exports = function(crowi, app) {
       if (page == null) {
       if (page == null) {
         throw new Error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden');
         throw new Error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden');
       }
       }
-
-      if (isRecursively) {
-        page = await Page.revertDeletedPageRecursively(page, req.user, { socketClientId });
-      }
-      else {
-        page = await Page.revertDeletedPage(page, req.user, { socketClientId });
-      }
+      page = await crowi.pageService.revertDeletedPage(page, req.user, { socketClientId }, isRecursively);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Error occured while get setting', err);
       logger.error('Error occured while get setting', err);
@@ -1359,12 +1346,7 @@ module.exports = function(crowi, app) {
         return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
         return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
       }
       }
 
 
-      if (isRecursively) {
-        page = await Page.renameRecursively(page, newPagePath, req.user, options);
-      }
-      else {
-        page = await Page.rename(page, newPagePath, req.user, options);
-      }
+      page = await crowi.pageService.renamePage(page, newPagePath, req.user, options, isRecursively);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);

+ 20 - 17
src/server/service/app.js

@@ -38,17 +38,9 @@ class AppService extends S2sMessageHandlable {
   async handleS2sMessage(s2sMessage) {
   async handleS2sMessage(s2sMessage) {
     logger.info('Invoke post installation process by pubsub notification');
     logger.info('Invoke post installation process by pubsub notification');
 
 
-    const { crowi, configManager, s2sMessagingService } = this;
-
-    // load config and setup
-    await configManager.loadConfigs();
-
-    const isInstalled = this.crowi.configManager.getConfig('crowi', 'app:installed');
-    if (isInstalled) {
-      crowi.setupAfterInstall();
-
-      // remove message handler
-      s2sMessagingService.removeMessageHandler(this);
+    const isDBInitialized = await this.isDBInitialized(true);
+    if (isDBInitialized) {
+      this.setupAfterInstall();
     }
     }
   }
   }
 
 
@@ -64,9 +56,6 @@ class AppService extends S2sMessageHandlable {
       catch (e) {
       catch (e) {
         logger.error('Failed to publish post installation message with S2sMessagingService: ', e.message);
         logger.error('Failed to publish post installation message with S2sMessagingService: ', e.message);
       }
       }
-
-      // remove message handler
-      s2sMessagingService.removeMessageHandler(this);
     }
     }
 
 
   }
   }
@@ -113,9 +102,23 @@ class AppService extends S2sMessageHandlable {
     await this.configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
     await this.configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
   }
   }
 
 
-  async isDBInitialized() {
-    const appInstalled = await this.configManager.getConfigFromDB('crowi', 'app:installed');
-    return appInstalled;
+  async isDBInitialized(forceReload) {
+    if (forceReload) {
+      // load configs
+      await this.configManager.loadConfigs();
+    }
+    return this.configManager.getConfigFromDB('crowi', 'app:installed');
+  }
+
+  async setupAfterInstall() {
+    this.crowi.pluginService.autoDetectAndLoadPlugins();
+    this.crowi.setupRoutesAtLast();
+
+    // remove message handler
+    const { s2sMessagingService } = this;
+    if (s2sMessagingService != null) {
+      this.s2sMessagingService.removeMessageHandler(this);
+    }
   }
   }
 
 
 }
 }

+ 23 - 3
src/server/service/attachment.js

@@ -1,5 +1,5 @@
 const logger = require('@alias/logger')('growi:service:AttachmentService'); // eslint-disable-line no-unused-vars
 const logger = require('@alias/logger')('growi:service:AttachmentService'); // eslint-disable-line no-unused-vars
-
+const mongoose = require('mongoose');
 const fs = require('fs');
 const fs = require('fs');
 
 
 
 
@@ -43,14 +43,34 @@ class AttachmentService {
     return attachment;
     return attachment;
   }
   }
 
 
+  async removeAllAttachments(attachments) {
+    const { fileUploadService } = this.crowi;
+    const attachmentsCollection = mongoose.connection.collection('attachments');
+    const unorderAttachmentsBulkOp = attachmentsCollection.initializeUnorderedBulkOp();
+
+    if (attachments.length === 0) {
+      return;
+    }
+
+    attachments.forEach((attachment) => {
+      unorderAttachmentsBulkOp.find({ _id: attachment._id }).remove();
+    });
+    await unorderAttachmentsBulkOp.execute();
+
+    await fileUploadService.deleteFiles(attachments);
+
+    return;
+  }
+
   async removeAttachment(attachmentId) {
   async removeAttachment(attachmentId) {
     const Attachment = this.crowi.model('Attachment');
     const Attachment = this.crowi.model('Attachment');
     const { fileUploadService } = this.crowi;
     const { fileUploadService } = this.crowi;
-
     const attachment = await Attachment.findById(attachmentId);
     const attachment = await Attachment.findById(attachmentId);
+
     await fileUploadService.deleteFile(attachment);
     await fileUploadService.deleteFile(attachment);
+    await attachment.remove();
 
 
-    return attachment.remove();
+    return;
   }
   }
 
 
 }
 }

+ 18 - 0
src/server/service/config-loader.js

@@ -380,6 +380,24 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.NUMBER,
     type:    TYPES.NUMBER,
     default: 120,
     default: 120,
   },
   },
+  PROMSTER_ENABLED: {
+    ns:      'crowi',
+    key:     'promster:isEnabled',
+    type:    TYPES.BOOLEAN,
+    default: false,
+  },
+  PROMSTER_PORT: {
+    ns:      'crowi',
+    key:     'promster:port',
+    type:    TYPES.NUMBER,
+    default: 7788,
+  },
+  GROWI_CLOUD_URI: {
+    ns:      'crowi',
+    key:     'app:growiCloudUri',
+    type:    TYPES.STRING,
+    default: null,
+  },
 };
 };
 
 
 class ConfigLoader {
 class ConfigLoader {

+ 18 - 1
src/server/service/file-uploader/aws.js

@@ -115,11 +115,28 @@ module.exports = function(crowi) {
     return lib.deleteFileByFilePath(filePath);
     return lib.deleteFileByFilePath(filePath);
   };
   };
 
 
-  lib.deleteFileByFilePath = async function(filePath) {
+  lib.deleteFiles = async function(attachments) {
     if (!this.getIsUploadable()) {
     if (!this.getIsUploadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
+    const s3 = S3Factory();
+    const awsConfig = getAwsConfig();
 
 
+    const filePaths = attachments.map((attachment) => {
+      return { Key: getFilePathOnStorage(attachment) };
+    });
+
+    const totalParams = {
+      Bucket: awsConfig.bucket,
+      Delete: { Objects: filePaths },
+    };
+    return s3.deleteObjects(totalParams).promise();
+  };
+
+  lib.deleteFileByFilePath = async function(filePath) {
+    if (!this.getIsUploadable()) {
+      throw new Error('AWS is not configured.');
+    }
     const s3 = S3Factory();
     const s3 = S3Factory();
     const awsConfig = getAwsConfig();
     const awsConfig = getAwsConfig();
 
 

+ 17 - 12
src/server/service/file-uploader/gcs.js

@@ -71,7 +71,7 @@ module.exports = function(crowi) {
 
 
     // issue signed url (default: expires 120 seconds)
     // issue signed url (default: expires 120 seconds)
     // https://cloud.google.com/storage/docs/access-control/signed-urls
     // https://cloud.google.com/storage/docs/access-control/signed-urls
-    const signedUrl = await file.getSignedUrl({
+    const [signedUrl] = await file.getSignedUrl({
       action: 'read',
       action: 'read',
       expires: Date.now() + lifetimeSecForTemporaryUrl * 1000,
       expires: Date.now() + lifetimeSecForTemporaryUrl * 1000,
     });
     });
@@ -87,28 +87,33 @@ module.exports = function(crowi) {
 
 
   };
   };
 
 
-  lib.deleteFile = async function(attachment) {
+  lib.deleteFile = function(attachment) {
     const filePath = getFilePathOnStorage(attachment);
     const filePath = getFilePathOnStorage(attachment);
-    return lib.deleteFileByFilePath(filePath);
+    return lib.deleteFilesByFilePaths([filePath]);
   };
   };
 
 
-  lib.deleteFileByFilePath = async function(filePath) {
+  lib.deleteFiles = function(attachments) {
+    const filePaths = attachments.map((attachment) => {
+      return getFilePathOnStorage(attachment);
+    });
+    return lib.deleteFilesByFilePaths(filePaths);
+  };
+
+  lib.deleteFilesByFilePaths = function(filePaths) {
     if (!this.getIsUploadable()) {
     if (!this.getIsUploadable()) {
       throw new Error('GCS is not configured.');
       throw new Error('GCS is not configured.');
     }
     }
 
 
     const gcs = getGcsInstance();
     const gcs = getGcsInstance();
     const myBucket = gcs.bucket(getGcsBucket());
     const myBucket = gcs.bucket(getGcsBucket());
-    const file = myBucket.file(filePath);
 
 
-    // check file exists
-    const isExists = await isFileExists(file);
-    if (!isExists) {
-      logger.warn(`Any object that relate to the Attachment (${filePath}) does not exist in GCS`);
-      return;
-    }
+    const files = filePaths.map((filePath) => {
+      return myBucket.file(filePath);
+    });
 
 
-    return file.delete();
+    files.forEach((file) => {
+      file.delete({ ignoreNotFound: true });
+    });
   };
   };
 
 
   lib.uploadFile = function(fileStream, attachment) {
   lib.uploadFile = function(fileStream, attachment) {

+ 16 - 3
src/server/service/file-uploader/gridfs.js

@@ -6,7 +6,7 @@ module.exports = function(crowi) {
   const Uploader = require('./uploader');
   const Uploader = require('./uploader');
   const lib = new Uploader(crowi);
   const lib = new Uploader(crowi);
   const COLLECTION_NAME = 'attachmentFiles';
   const COLLECTION_NAME = 'attachmentFiles';
-  // const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
+  const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
 
 
   // instantiate mongoose-gridfs
   // instantiate mongoose-gridfs
   const { createModel } = require('mongoose-gridfs');
   const { createModel } = require('mongoose-gridfs');
@@ -15,8 +15,9 @@ module.exports = function(crowi) {
     bucketName: COLLECTION_NAME,
     bucketName: COLLECTION_NAME,
     connection: mongoose.connection,
     connection: mongoose.connection,
   });
   });
+
   // get Collection instance of chunk
   // get Collection instance of chunk
-  // const chunkCollection = mongoose.connection.collection(CHUNK_COLLECTION_NAME);
+  const chunkCollection = mongoose.connection.collection(CHUNK_COLLECTION_NAME);
 
 
   // create promisified method
   // create promisified method
   AttachmentFile.promisifiedWrite = util.promisify(AttachmentFile.write).bind(AttachmentFile);
   AttachmentFile.promisifiedWrite = util.promisify(AttachmentFile.write).bind(AttachmentFile);
@@ -39,10 +40,22 @@ module.exports = function(crowi) {
       logger.warn(`Any AttachmentFile that relate to the Attachment (${attachment._id.toString()}) does not exist in GridFS`);
       logger.warn(`Any AttachmentFile that relate to the Attachment (${attachment._id.toString()}) does not exist in GridFS`);
       return;
       return;
     }
     }
-
     return AttachmentFile.promisifiedUnlink({ _id: attachmentFile._id });
     return AttachmentFile.promisifiedUnlink({ _id: attachmentFile._id });
   };
   };
 
 
+  lib.deleteFiles = async function(attachments) {
+    const filenameValues = attachments.map((attachment) => {
+      return attachment.fileName;
+    });
+    const fileIdObjects = await AttachmentFile.find({ filename: { $in: filenameValues } }, { _id: 1 });
+    const idsRelatedFiles = fileIdObjects.map((obj) => { return obj._id });
+
+    return Promise.all([
+      AttachmentFile.deleteMany({ filename: { $in: filenameValues } }),
+      chunkCollection.deleteMany({ files_id: { $in: idsRelatedFiles } }),
+    ]);
+  };
+
   /**
   /**
    * get size of data uploaded files using (Promise wrapper)
    * get size of data uploaded files using (Promise wrapper)
    */
    */

+ 6 - 0
src/server/service/file-uploader/local.js

@@ -35,6 +35,12 @@ module.exports = function(crowi) {
     return lib.deleteFileByFilePath(filePath);
     return lib.deleteFileByFilePath(filePath);
   };
   };
 
 
+  lib.deleteFiles = async function(attachments) {
+    attachments.map((attachment) => {
+      return this.deleteFile(attachment);
+    });
+  };
+
   lib.deleteFileByFilePath = async function(filePath) {
   lib.deleteFileByFilePath = async function(filePath) {
     // check file exists
     // check file exists
     try {
     try {

+ 5 - 0
src/server/service/file-uploader/none.js

@@ -14,6 +14,11 @@ module.exports = function(crowi) {
     throw new Error('not implemented');
     throw new Error('not implemented');
   };
   };
 
 
+  lib.deleteFiles = function(filePath) {
+    debug(`File deletion: ${filePath}`);
+    throw new Error('not implemented');
+  };
+
   lib.uploadFile = function(filePath, contentType, fileStream, options) {
   lib.uploadFile = function(filePath, contentType, fileStream, options) {
     debug(`File uploading: ${filePath}`);
     debug(`File uploading: ${filePath}`);
     throw new Error('not implemented');
     throw new Error('not implemented');

+ 4 - 0
src/server/service/file-uploader/uploader.js

@@ -29,6 +29,10 @@ class Uploader {
     return !!this.configManager.getConfig('crowi', 'app:fileUpload');
     return !!this.configManager.getConfig('crowi', 'app:fileUpload');
   }
   }
 
 
+  deleteFiles() {
+    throw new Error('Implemnt this');
+  }
+
   /**
   /**
    * Check files size limits for all uploaders
    * Check files size limits for all uploaders
    *
    *

+ 684 - 30
src/server/service/page.js

@@ -1,14 +1,191 @@
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const escapeStringRegexp = require('escape-string-regexp');
 const escapeStringRegexp = require('escape-string-regexp');
+const logger = require('@alias/logger')('growi:models:page');
+const debug = require('debug')('growi:models:page');
+const { Writable } = require('stream');
+const { createBatchStream } = require('@server/util/batch-stream');
+const { isTrashPage } = require('@commons/util/path-utils');
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 
 
+const BULK_REINDEX_SIZE = 100;
+
 class PageService {
 class PageService {
 
 
   constructor(crowi) {
   constructor(crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
+    this.pageEvent = crowi.event('page');
+
+    // init
+    this.pageEvent.on('create', this.pageEvent.onCreate);
+    this.pageEvent.on('update', this.pageEvent.onUpdate);
+    this.pageEvent.on('createMany', this.pageEvent.onCreateMany);
+  }
+
+  /**
+   * go back by using redirectTo and return the paths
+   *  ex: when
+   *    '/page1' redirects to '/page2' and
+   *    '/page2' redirects to '/page3'
+   *    and given '/page3',
+   *    '/page1' and '/page2' will be return
+   *
+   * @param {string} redirectTo
+   * @param {object} redirectToPagePathMapping
+   * @param {array} pagePaths
+   */
+  prepareShoudDeletePagesByRedirectTo(redirectTo, redirectToPagePathMapping, pagePaths = []) {
+    const pagePath = redirectToPagePathMapping[redirectTo];
+
+    if (pagePath == null) {
+      return pagePaths;
+    }
+
+    pagePaths.push(pagePath);
+    return this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping, pagePaths);
+  }
+
+
+  async renamePage(page, newPagePath, user, options, isRecursively = false) {
+
+    const Page = this.crowi.model('Page');
+    const Revision = this.crowi.model('Revision');
+    const path = page.path;
+    const createRedirectPage = options.createRedirectPage || false;
+    const updateMetadata = options.updateMetadata || false;
+    const socketClientId = options.socketClientId || null;
+
+    // sanitize path
+    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
+
+    const update = {};
+    // update Page
+    update.path = newPagePath;
+    if (updateMetadata) {
+      update.lastUpdateUser = user;
+      update.updatedAt = Date.now();
+    }
+    const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
+
+    // update Rivisions
+    await Revision.updateRevisionListByPath(path, { path: newPagePath }, {});
+
+    if (createRedirectPage) {
+      const body = `redirect ${newPagePath}`;
+      await Page.create(path, body, user, { redirectTo: newPagePath });
+    }
+
+    if (isRecursively) {
+      this.renameDescendantsWithStream(page, newPagePath, user, options);
+    }
+
+    this.pageEvent.emit('delete', page, user, socketClientId);
+    this.pageEvent.emit('create', renamedPage, user, socketClientId);
+
+    return renamedPage;
+  }
+
+
+  async renameDescendants(pages, user, options, oldPagePathPrefix, newPagePathPrefix) {
+    const Page = this.crowi.model('Page');
+
+    const pageCollection = mongoose.connection.collection('pages');
+    const revisionCollection = mongoose.connection.collection('revisions');
+    const { updateMetadata, createRedirectPage } = options;
+
+    const unorderedBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const createRediectPageBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const revisionUnorderedBulkOp = revisionCollection.initializeUnorderedBulkOp();
+    const createRediectRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
+
+    pages.forEach((page) => {
+      const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
+      const revisionId = new mongoose.Types.ObjectId();
+
+      if (updateMetadata) {
+        unorderedBulkOp.find({ _id: page._id }).update([{ $set: { path: newPagePath, lastUpdateUser: user._id, updatedAt: { $toDate: Date.now() } } }]);
+      }
+      else {
+        unorderedBulkOp.find({ _id: page._id }).update({ $set: { path: newPagePath } });
+      }
+      if (createRedirectPage) {
+        createRediectPageBulkOp.insert({
+          path: page.path, revision: revisionId, creator: user._id, lastUpdateUser: user._id, status: Page.STATUS_PUBLISHED, redirectTo: newPagePath,
+        });
+        createRediectRevisionBulkOp.insert({
+          _id: revisionId, path: page.path, body: `redirect ${newPagePath}`, author: user._id, format: 'markdown',
+        });
+      }
+      revisionUnorderedBulkOp.find({ path: page.path }).update({ $set: { path: newPagePath } }, { multi: true });
+    });
+
+    try {
+      await unorderedBulkOp.execute();
+      await revisionUnorderedBulkOp.execute();
+      // Execute after unorderedBulkOp to prevent duplication
+      if (createRedirectPage) {
+        await createRediectPageBulkOp.execute();
+        await createRediectRevisionBulkOp.execute();
+      }
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw new Error('Failed to rename pages: ', err);
+      }
+    }
+
+    this.pageEvent.emit('updateMany', pages, user);
+  }
+
+  /**
+   * Create rename stream
+   */
+  async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}) {
+    const Page = this.crowi.model('Page');
+    const newPagePathPrefix = newPagePath;
+    const { PageQueryBuilder } = Page;
+    const pathRegExp = new RegExp(`^${escapeStringRegexp(targetPage.path)}`, 'i');
+
+    const readStream = new PageQueryBuilder(Page.find())
+      .addConditionToExcludeRedirect()
+      .addConditionToListOnlyDescendants(targetPage.path)
+      .addConditionToFilteringByViewer(user)
+      .query
+      .lean()
+      .cursor();
+
+    const renameDescendants = this.renameDescendants.bind(this);
+    const pageEvent = this.pageEvent;
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          await renameDescendants(batch, user, options, pathRegExp, newPagePathPrefix);
+          logger.debug(`Reverting pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('revertPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Reverting pages has completed: (totalCount=${count})`);
+        // update  path
+        targetPage.path = newPagePath;
+        pageEvent.emit('syncDescendants', targetPage, user);
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
   }
   }
 
 
-  async deleteCompletely(pageId, pagePath) {
+
+  async deleteCompletelyOperation(pageIds, pagePaths) {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     const Bookmark = this.crowi.model('Bookmark');
     const Bookmark = this.crowi.model('Bookmark');
     const Comment = this.crowi.model('Comment');
     const Comment = this.crowi.model('Comment');
@@ -16,33 +193,34 @@ class PageService {
     const PageTagRelation = this.crowi.model('PageTagRelation');
     const PageTagRelation = this.crowi.model('PageTagRelation');
     const ShareLink = this.crowi.model('ShareLink');
     const ShareLink = this.crowi.model('ShareLink');
     const Revision = this.crowi.model('Revision');
     const Revision = this.crowi.model('Revision');
-
-    return Promise.all([
-      Bookmark.removeBookmarksByPageId(pageId),
-      Comment.removeCommentsByPageId(pageId),
-      PageTagRelation.remove({ relatedPage: pageId }),
-      ShareLink.remove({ relatedPage: pageId }),
-      Revision.removeRevisionsByPath(pagePath),
-      Page.findByIdAndRemove(pageId),
-      Page.removeRedirectOriginPageByPath(pagePath),
-      this.removeAllAttachments(pageId),
-    ]);
-  }
-
-  async removeAllAttachments(pageId) {
     const Attachment = this.crowi.model('Attachment');
     const Attachment = this.crowi.model('Attachment');
+
     const { attachmentService } = this.crowi;
     const { attachmentService } = this.crowi;
+    const attachments = await Attachment.find({ page: { $in: pageIds } });
 
 
-    const attachments = await Attachment.find({ page: pageId });
+    const pages = await Page.find({ redirectTo: { $ne: null } });
+    const redirectToPagePathMapping = {};
+    pages.forEach((page) => {
+      redirectToPagePathMapping[page.redirectTo] = page.path;
+    });
 
 
-    const promises = attachments.map(async(attachment) => {
-      return attachmentService.removeAttachment(attachment._id);
+    const redirectedFromPagePaths = [];
+    pagePaths.forEach((pagePath) => {
+      redirectedFromPagePaths.push(...this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping));
     });
     });
 
 
-    return Promise.all(promises);
+    return Promise.all([
+      Bookmark.deleteMany({ page: { $in: pageIds } }),
+      Comment.deleteMany({ page: { $in: pageIds } }),
+      PageTagRelation.deleteMany({ relatedPage: { $in: pageIds } }),
+      ShareLink.deleteMany({ relatedPage: { $in: pageIds } }),
+      Revision.deleteMany({ path: { $in: pagePaths } }),
+      Page.deleteMany({ $or: [{ path: { $in: pagePaths } }, { path: { $in: redirectedFromPagePaths } }, { _id: { $in: pageIds } }] }),
+      attachmentService.removeAllAttachments(attachments),
+    ]);
   }
   }
 
 
-  async duplicate(page, newPagePath, user) {
+  async duplicate(page, newPagePath, user, isRecursively) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const PageTagRelation = mongoose.model('PageTagRelation');
     const PageTagRelation = mongoose.model('PageTagRelation');
     // populate
     // populate
@@ -54,10 +232,16 @@ class PageService {
     options.grantUserGroupId = page.grantedGroup;
     options.grantUserGroupId = page.grantedGroup;
     options.grantedUsers = page.grantedUsers;
     options.grantedUsers = page.grantedUsers;
 
 
+    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
+
     const createdPage = await Page.create(
     const createdPage = await Page.create(
       newPagePath, page.revision.body, user, options,
       newPagePath, page.revision.body, user, options,
     );
     );
 
 
+    if (isRecursively) {
+      this.duplicateDescendantsWithStream(page, newPagePath, user);
+    }
+
     // take over tags
     // take over tags
     const originTags = await page.findRelatedTagsById();
     const originTags = await page.findRelatedTagsById();
     let savedTags = [];
     let savedTags = [];
@@ -72,28 +256,498 @@ class PageService {
     return result;
     return result;
   }
   }
 
 
-  async duplicateRecursively(page, newPagePath, user) {
+  /**
+   * Receive the object with oldPageId and newPageId and duplicate the tags from oldPage to newPage
+   * @param {Object} pageIdMapping e.g. key: oldPageId, value: newPageId
+   */
+  async duplicateTags(pageIdMapping) {
+    const PageTagRelation = mongoose.model('PageTagRelation');
+
+    // convert pageId from string to ObjectId
+    const pageIds = Object.keys(pageIdMapping);
+    const stage = { $or: pageIds.map((pageId) => { return { relatedPage: mongoose.Types.ObjectId(pageId) } }) };
+
+    const pagesAssociatedWithTag = await PageTagRelation.aggregate([
+      {
+        $match: stage,
+      },
+      {
+        $group: {
+          _id: '$relatedTag',
+          relatedPages: { $push: '$relatedPage' },
+        },
+      },
+    ]);
+
+    const newPageTagRelation = [];
+    pagesAssociatedWithTag.forEach(({ _id, relatedPages }) => {
+      // relatedPages
+      relatedPages.forEach((pageId) => {
+        newPageTagRelation.push({
+          relatedPage: pageIdMapping[pageId], // newPageId
+          relatedTag: _id,
+        });
+      });
+    });
+
+    return PageTagRelation.insertMany(newPageTagRelation, { ordered: false });
+  }
+
+  async duplicateDescendants(pages, user, oldPagePathPrefix, newPagePathPrefix) {
+    const Page = this.crowi.model('Page');
+    const Revision = this.crowi.model('Revision');
+
+    const paths = pages.map(page => (page.path));
+    const revisions = await Revision.find({ path: { $in: paths } });
+
+    // Mapping to set to the body of the new revision
+    const pathRevisionMapping = {};
+    revisions.forEach((revision) => {
+      pathRevisionMapping[revision.path] = revision;
+    });
+
+    // key: oldPageId, value: newPageId
+    const pageIdMapping = {};
+    const newPages = [];
+    const newRevisions = [];
+
+    pages.forEach((page) => {
+      const newPageId = new mongoose.Types.ObjectId();
+      const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
+      const revisionId = new mongoose.Types.ObjectId();
+      pageIdMapping[page._id] = newPageId;
+
+      newPages.push({
+        _id: newPageId,
+        path: newPagePath,
+        creator: user._id,
+        grant: page.grant,
+        grantedGroup: page.grantedGroup,
+        grantedUsers: page.grantedUsers,
+        lastUpdateUser: user._id,
+        redirectTo: null,
+        revision: revisionId,
+      });
+
+      newRevisions.push({
+        _id: revisionId, path: newPagePath, body: pathRevisionMapping[page.path].body, author: user._id, format: 'markdown',
+      });
+
+    });
+
+    await Page.insertMany(newPages, { ordered: false });
+    await Revision.insertMany(newRevisions, { ordered: false });
+    await this.duplicateTags(pageIdMapping);
+  }
+
+  async duplicateDescendantsWithStream(page, newPagePath, user) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const newPagePathPrefix = newPagePath;
     const newPagePathPrefix = newPagePath;
     const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
     const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
 
 
-    const pages = await Page.findManageableListWithDescendants(page, user);
+    const { PageQueryBuilder } = Page;
+
+    const readStream = new PageQueryBuilder(Page.find())
+      .addConditionToExcludeRedirect()
+      .addConditionToListOnlyDescendants(page.path)
+      .addConditionToFilteringByViewer(user)
+      .query
+      .lean()
+      .cursor();
+
+    const duplicateDescendants = this.duplicateDescendants.bind(this);
+    const pageEvent = this.pageEvent;
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix);
+          logger.debug(`Adding pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('addAllPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Adding pages has completed: (totalCount=${count})`);
+        // update  path
+        page.path = newPagePath;
+        pageEvent.emit('syncDescendants', page, user);
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+
+  }
+
+
+  async deletePage(page, user, options = {}, isRecursively = false) {
+    const Page = this.crowi.model('Page');
+    const Revision = this.crowi.model('Revision');
+
+    const newPath = Page.getDeletedPageName(page.path);
+    const isTrashed = isTrashPage(page.path);
+
+    if (isTrashed) {
+      throw new Error('This method does NOT support deleting trashed pages.');
+    }
+
+    const socketClientId = options.socketClientId || null;
+    if (!Page.isDeletableName(page.path)) {
+      throw new Error('Page is not deletable.');
+    }
+
+    if (isRecursively) {
+      this.deleteDescendantsWithStream(page, user, options);
+    }
+
+    // update Rivisions
+    await Revision.updateRevisionListByPath(page.path, { path: newPath }, {});
+    const deletedPage = await Page.findByIdAndUpdate(page._id, {
+      $set: {
+        path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
+      },
+    }, { new: true });
+    const body = `redirect ${newPath}`;
+    await Page.create(page.path, body, user, { redirectTo: newPath });
+
+    this.pageEvent.emit('delete', page, user, socketClientId);
+    this.pageEvent.emit('create', deletedPage, user, socketClientId);
+
+    return deletedPage;
+  }
+
+  async deleteDescendants(pages, user) {
+    const Page = this.crowi.model('Page');
+
+    const pageCollection = mongoose.connection.collection('pages');
+    const revisionCollection = mongoose.connection.collection('revisions');
+
+    const deletePageBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const updateRevisionListOp = revisionCollection.initializeUnorderedBulkOp();
+    const createRediectRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
+    const newPagesForRedirect = [];
+
+    pages.forEach((page) => {
+      const newPath = Page.getDeletedPageName(page.path);
+      const revisionId = new mongoose.Types.ObjectId();
+      const body = `redirect ${newPath}`;
+
+      deletePageBulkOp.find({ _id: page._id }).update({
+        $set: {
+          path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
+        },
+      });
+      updateRevisionListOp.find({ path: page.path }).update({ $set: { path: newPath } });
+      createRediectRevisionBulkOp.insert({
+        _id: revisionId, path: page.path, body, author: user._id, format: 'markdown',
+      });
+
+      newPagesForRedirect.push({
+        path: page.path,
+        creator: user._id,
+        grant: page.grant,
+        grantedGroup: page.grantedGroup,
+        grantedUsers: page.grantedUsers,
+        lastUpdateUser: user._id,
+        redirectTo: newPath,
+        revision: revisionId,
+      });
+    });
+
+    try {
+      await deletePageBulkOp.execute();
+      await updateRevisionListOp.execute();
+      await createRediectRevisionBulkOp.execute();
+      await Page.insertMany(newPagesForRedirect, { ordered: false });
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw new Error('Failed to revert pages: ', err);
+      }
+    }
+  }
+
+  /**
+   * Create delete stream
+   */
+  async deleteDescendantsWithStream(targetPage, user, options = {}) {
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    const readStream = new PageQueryBuilder(Page.find())
+      .addConditionToExcludeRedirect()
+      .addConditionToListOnlyDescendants(targetPage.path)
+      .addConditionToFilteringByViewer(user)
+      .query
+      .lean()
+      .cursor();
+
+    const deleteDescendants = this.deleteDescendants.bind(this);
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          deleteDescendants(batch, user);
+          logger.debug(`Reverting pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('revertPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Reverting pages has completed: (totalCount=${count})`);
+
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+  }
+
+  // delete multiple pages
+  async deleteMultipleCompletely(pages, user, options = {}) {
+    const ids = pages.map(page => (page._id));
+    const paths = pages.map(page => (page.path));
+    const socketClientId = options.socketClientId || null;
+
+    logger.debug('Deleting completely', paths);
+
+    await this.deleteCompletelyOperation(ids, paths);
+
+    this.pageEvent.emit('deleteCompletely', pages, user, socketClientId); // update as renamed page
+
+    return;
+  }
+
+  async deleteCompletely(page, user, options = {}, isRecursively = false) {
+    const ids = [page._id];
+    const paths = [page.path];
+    const socketClientId = options.socketClientId || null;
+
+    logger.debug('Deleting completely', paths);
+
+    await this.deleteCompletelyOperation(ids, paths);
+
+    if (isRecursively) {
+      this.deleteCompletelyDescendantsWithStream(page, user, options);
+    }
 
 
-    const promise = pages.map(async(page) => {
-      const newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
-      return this.duplicate(page, newPagePath, user);
+    this.pageEvent.emit('delete', page, user, socketClientId); // update as renamed page
+
+    return;
+  }
+
+  /**
+   * Create delete completely stream
+   */
+  async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}) {
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    const readStream = new PageQueryBuilder(Page.find())
+      .addConditionToExcludeRedirect()
+      .addConditionToListOnlyDescendants(targetPage.path)
+      .addConditionToFilteringByViewer(user)
+      .query
+      .lean()
+      .cursor();
+
+    const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          await deleteMultipleCompletely(batch, user, options);
+          logger.debug(`Adding pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('addAllPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Adding pages has completed: (totalCount=${count})`);
+
+        callback();
+      },
     });
     });
 
 
-    const newPath = page.path.replace(pathRegExp, newPagePathPrefix);
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+  }
+
+  async revertDeletedDescendants(pages, user) {
+    const Page = this.crowi.model('Page');
+    const pageCollection = mongoose.connection.collection('pages');
+    const revisionCollection = mongoose.connection.collection('revisions');
 
 
-    await Promise.allSettled(promise);
+    const removePageBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const revertPageBulkOp = pageCollection.initializeUnorderedBulkOp();
+    const revertRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
 
 
-    const newParentpage = await Page.findByPath(newPath);
+    // e.g. key: '/test'
+    const pathToPageMapping = {};
+    const toPaths = pages.map(page => Page.getRevertDeletedPageName(page.path));
+    const toPages = await Page.find({ path: { $in: toPaths } });
+    toPages.forEach((toPage) => {
+      pathToPageMapping[toPage.path] = toPage;
+    });
 
 
-    // TODO GW-4634 use stream
-    return newParentpage;
+    pages.forEach((page) => {
+
+      // e.g. page.path = /trash/test, toPath = /test
+      const toPath = Page.getRevertDeletedPageName(page.path);
+
+      if (pathToPageMapping[toPath] != null) {
+      // When the page is deleted, it will always be created with "redirectTo" in the path of the original page.
+      // So, it's ok to delete the page
+      // However, If a page exists that is not "redirectTo", something is wrong. (Data correction is needed).
+        if (pathToPageMapping[toPath].redirectTo === page.path) {
+          removePageBulkOp.find({ path: toPath }).remove();
+        }
+      }
+      revertPageBulkOp.find({ _id: page._id }).update({
+        $set: {
+          path: toPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
+        },
+      });
+      revertRevisionBulkOp.find({ path: page.path }).update({ $set: { path: toPath } }, { multi: true });
+    });
+
+    try {
+      await removePageBulkOp.execute();
+      await revertPageBulkOp.execute();
+      await revertRevisionBulkOp.execute();
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw new Error('Failed to revert pages: ', err);
+      }
+    }
   }
   }
 
 
+  async revertDeletedPage(page, user, options = {}, isRecursively = false) {
+    const Page = this.crowi.model('Page');
+    const Revision = this.crowi.model('Revision');
+
+    const newPath = Page.getRevertDeletedPageName(page.path);
+    const originPage = await Page.findByPath(newPath);
+    if (originPage != null) {
+      // When the page is deleted, it will always be created with "redirectTo" in the path of the original page.
+      // So, it's ok to delete the page
+      // However, If a page exists that is not "redirectTo", something is wrong. (Data correction is needed).
+      if (originPage.redirectTo !== page.path) {
+        throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
+      }
+      await this.deleteCompletely(originPage, options);
+    }
+
+    if (isRecursively) {
+      this.revertDeletedDescendantsWithStream(page, user, options);
+    }
+
+    page.status = Page.STATUS_PUBLISHED;
+    page.lastUpdateUser = user;
+    debug('Revert deleted the page', page, newPath);
+    const updatedPage = await Page.findByIdAndUpdate(page._id, {
+      $set: {
+        path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
+      },
+    }, { new: true });
+    await Revision.updateMany({ path: page.path }, { $set: { path: newPath } });
+
+    return updatedPage;
+  }
+
+  /**
+   * Create revert stream
+   */
+  async revertDeletedDescendantsWithStream(targetPage, user, options = {}) {
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    const readStream = new PageQueryBuilder(Page.find())
+      .addConditionToExcludeRedirect()
+      .addConditionToListOnlyDescendants(targetPage.path)
+      .addConditionToFilteringByViewer(user)
+      .query
+      .lean()
+      .cursor();
+
+    const revertDeletedDescendants = this.revertDeletedDescendants.bind(this);
+    let count = 0;
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          revertDeletedDescendants(batch, user);
+          logger.debug(`Reverting pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('revertPages error on add anyway: ', err);
+        }
+
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Reverting pages has completed: (totalCount=${count})`);
+
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+  }
+
+
+  async handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId) {
+    const Page = this.crowi.model('Page');
+    const pages = await Page.find({ grantedGroup: deletedGroup });
+
+    switch (action) {
+      case 'public':
+        await Promise.all(pages.map((page) => {
+          return Page.publicizePage(page);
+        }));
+        break;
+      case 'delete':
+        return this.deleteMultiplePagesCompletely(pages);
+      case 'transfer':
+        await Promise.all(pages.map((page) => {
+          return Page.transferPageToGroup(page, transferToUserGroupId);
+        }));
+        break;
+      default:
+        throw new Error('Unknown action for private pages');
+    }
+  }
+
+  validateCrowi() {
+    if (this.crowi == null) {
+      throw new Error('"crowi" is null. Init User model with "crowi" argument first.');
+    }
+  }
 
 
 }
 }
 
 

+ 47 - 6
src/server/service/search-delegator/elasticsearch.js

@@ -352,6 +352,14 @@ class ElasticsearchDelegator {
     return this.updateOrInsertPages(() => Page.findById(pageId));
     return this.updateOrInsertPages(() => Page.findById(pageId));
   }
   }
 
 
+  updateOrInsertDescendantsPagesById(page, user) {
+    const Page = mongoose.model('Page');
+    const { PageQueryBuilder } = Page;
+    const builder = new PageQueryBuilder(Page.find());
+    builder.addConditionToListWithDescendants(page.path);
+    return this.updateOrInsertPages(() => builder.query);
+  }
+
   /**
   /**
    * @param {function} queryFactory factory method to generate a Mongoose Query instance
    * @param {function} queryFactory factory method to generate a Mongoose Query instance
    */
    */
@@ -501,13 +509,8 @@ class ElasticsearchDelegator {
   }
   }
 
 
   deletePages(pages) {
   deletePages(pages) {
-    const self = this;
     const body = [];
     const body = [];
-
-    pages.map((page) => {
-      self.prepareBodyForDelete(body, page);
-      return;
-    });
+    pages.forEach(page => this.prepareBodyForDelete(body, page));
 
 
     logger.debug('deletePages(): Sending Request to ES', body);
     logger.debug('deletePages(): Sending Request to ES', body);
     return this.client.bulk({
     return this.client.bulk({
@@ -963,6 +966,44 @@ class ElasticsearchDelegator {
     return this.updateOrInsertPageById(page._id);
     return this.updateOrInsertPageById(page._id);
   }
   }
 
 
+  // remove pages whitch should nod Indexed
+  async syncPagesUpdated(pages, user) {
+    const shoudDeletePages = [];
+    pages.forEach((page) => {
+      logger.debug('SearchClient.syncPageUpdated', page.path);
+      if (!this.shouldIndexed(page)) {
+        shoudDeletePages.append(page);
+      }
+    });
+
+    // delete if page should not indexed
+    try {
+      if (shoudDeletePages.length !== 0) {
+        await this.deletePages(shoudDeletePages);
+      }
+    }
+    catch (err) {
+      logger.error('deletePages:ES Error', err);
+    }
+  }
+
+  async syncDescendantsPagesUpdated(parentPage, user) {
+    return this.updateOrInsertDescendantsPagesById(parentPage, user);
+  }
+
+  async syncPagesDeletedCompletely(pages, user) {
+    for (let i = 0; i < pages.length; i++) {
+      logger.debug('SearchClient.syncPageDeleted', pages[i].path);
+    }
+
+    try {
+      return await this.deletePages(pages);
+    }
+    catch (err) {
+      logger.error('deletePages:ES Error', err);
+    }
+  }
+
   async syncPageDeleted(page, user) {
   async syncPageDeleted(page, user) {
     logger.debug('SearchClient.syncPageDeleted', page.path);
     logger.debug('SearchClient.syncPageDeleted', page.path);
 
 

+ 3 - 0
src/server/service/search.js

@@ -58,7 +58,10 @@ class SearchService {
     const pageEvent = this.crowi.event('page');
     const pageEvent = this.crowi.event('page');
     pageEvent.on('create', this.delegator.syncPageUpdated.bind(this.delegator));
     pageEvent.on('create', this.delegator.syncPageUpdated.bind(this.delegator));
     pageEvent.on('update', this.delegator.syncPageUpdated.bind(this.delegator));
     pageEvent.on('update', this.delegator.syncPageUpdated.bind(this.delegator));
+    pageEvent.on('deleteCompletely', this.delegator.syncPagesDeletedCompletely.bind(this.delegator));
     pageEvent.on('delete', this.delegator.syncPageDeleted.bind(this.delegator));
     pageEvent.on('delete', this.delegator.syncPageDeleted.bind(this.delegator));
+    pageEvent.on('updateMany', this.delegator.syncPagesUpdated.bind(this.delegator));
+    pageEvent.on('syncDescendants', this.delegator.syncDescendantsPagesUpdated.bind(this.delegator));
 
 
     const bookmarkEvent = this.crowi.event('bookmark');
     const bookmarkEvent = this.crowi.event('bookmark');
     bookmarkEvent.on('create', this.delegator.syncBookmarkChanged.bind(this.delegator));
     bookmarkEvent.on('create', this.delegator.syncBookmarkChanged.bind(this.delegator));

+ 1 - 1
src/server/views/installer.html

@@ -10,7 +10,7 @@
 
 
   <meta name="viewport" content="width=device-width,initial-scale=1">
   <meta name="viewport" content="width=device-width,initial-scale=1">
 
 
-  <meta name="apple-mobile-web-app-title" content="{{ appService.getAppTitle() }}">
+  <meta name="apple-mobile-web-app-title" content="{{ appService.getAppTitle() | preventXss }}">
 
 
   {% include './widget/headers/favicon.html' %}
   {% include './widget/headers/favicon.html' %}
   {% include './widget/headers/ie11-polyfills.html' %}
   {% include './widget/headers/ie11-polyfills.html' %}

+ 1 - 1
src/server/views/layout/layout.html

@@ -10,7 +10,7 @@
 
 
   <meta name="viewport" content="width=device-width,initial-scale=1">
   <meta name="viewport" content="width=device-width,initial-scale=1">
 
 
-  <meta name="apple-mobile-web-app-title" content="{{ appService.getAppTitle() }}">
+  <meta name="apple-mobile-web-app-title" content="{{ appService.getAppTitle() | preventXss }}">
 
 
   {{ getConfig('crowi', 'customize:header') | default('') }}
   {{ getConfig('crowi', 'customize:header') | default('') }}
 
 

+ 1 - 1
src/server/views/login.html

@@ -34,7 +34,7 @@
     <div class="col-md-12">
     <div class="col-md-12">
       <div class="login-header mx-auto">
       <div class="login-header mx-auto">
         <div class="logo mb-3">{% include 'widget/logo.html' %}</div>
         <div class="logo mb-3">{% include 'widget/logo.html' %}</div>
-        <h1>{{ appService.getAppTitle() }}</h1>
+        <h1>{{ appService.getAppTitle() | preventXss }}</h1>
 
 
           <div class="login-form-errors px-3">
           <div class="login-form-errors px-3">
             {% if isLdapSetupFailed() %}
             {% if isLdapSetupFailed() %}

+ 2 - 0
src/server/views/widget/page_content.html

@@ -23,6 +23,8 @@
   data-page-creator="{% if page && page.creator %}{{ page.creator|json }}{% endif %}"
   data-page-creator="{% if page && page.creator %}{{ page.creator|json }}{% endif %}"
   data-page-last-update-username="{% if page && page.lastUpdateUser %}{{ page.lastUpdateUser.name }}{% endif %}"
   data-page-last-update-username="{% if page && page.lastUpdateUser %}{{ page.lastUpdateUser.name }}{% endif %}"
   data-page-updated-at="{% if page %}{{ page.updatedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-updated-at="{% if page %}{{ page.updatedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
+  data-page-delete-username="{% if page && page.deleteUser %}{{ page.deleteUser.name }}{% endif %}"
+  data-page-deleted-at="{% if page %}{{ page.deletedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   data-page-ids-of-seen-users="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"
   data-page-ids-of-seen-users="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"

+ 723 - 0
src/test/service/page.test.js

@@ -0,0 +1,723 @@
+/* eslint-disable no-unused-vars */
+const mongoose = require('mongoose');
+
+const { getInstance } = require('../setup-crowi');
+
+let testUser1;
+let testUser2;
+let parentTag;
+let childTag;
+
+let parentForRename1;
+let parentForRename2;
+let parentForRename3;
+let parentForRename4;
+
+let childForRename1;
+let childForRename2;
+let childForRename3;
+
+let parentForDuplicate;
+
+let parentForDelete1;
+let parentForDelete2;
+
+let childForDelete;
+
+let parentForDeleteCompletely;
+
+let parentForRevert1;
+let parentForRevert2;
+
+let childForDuplicate;
+let childForDeleteCompletely;
+
+let childForRevert;
+
+describe('PageService', () => {
+
+  let crowi;
+  let Page;
+  let Revision;
+  let User;
+  let Tag;
+  let PageTagRelation;
+  let Bookmark;
+  let Comment;
+  let ShareLink;
+  let xssSpy;
+
+  beforeAll(async(done) => {
+    crowi = await getInstance();
+
+    User = mongoose.model('User');
+    Page = mongoose.model('Page');
+    Revision = mongoose.model('Revision');
+    Tag = mongoose.model('Tag');
+    PageTagRelation = mongoose.model('PageTagRelation');
+    Bookmark = mongoose.model('Bookmark');
+    Comment = mongoose.model('Comment');
+    ShareLink = mongoose.model('ShareLink');
+
+    await User.insertMany([
+      { name: 'someone1', username: 'someone1', email: 'someone1@example.com' },
+      { name: 'someone2', username: 'someone2', email: 'someone2@example.com' },
+    ]);
+
+    testUser1 = await User.findOne({ username: 'someone1' });
+    testUser2 = await User.findOne({ username: 'someone2' });
+
+    await Page.insertMany([
+      {
+        path: '/parentForRename1',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename2',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename3',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename4',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename1/child',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename2/child',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename3/child',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForDuplicate',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+        revision: '600d395667536503354cbe91',
+      },
+      {
+        path: '/parentForDuplicate/child',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+        revision: '600d395667536503354cbe92',
+      },
+      {
+        path: '/parentForDelete1',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForDelete2',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForDelete/child',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForDeleteCompletely',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForDeleteCompletely/child',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/trash/parentForRevert1',
+        status: Page.STATUS_DELETED,
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/trash/parentForRevert2',
+        status: Page.STATUS_DELETED,
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/trash/parentForRevert/child',
+        status: Page.STATUS_DELETED,
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+    ]);
+
+    parentForRename1 = await Page.findOne({ path: '/parentForRename1' });
+    parentForRename2 = await Page.findOne({ path: '/parentForRename2' });
+    parentForRename3 = await Page.findOne({ path: '/parentForRename3' });
+    parentForRename4 = await Page.findOne({ path: '/parentForRename4' });
+
+    parentForDuplicate = await Page.findOne({ path: '/parentForDuplicate' });
+
+    parentForDelete1 = await Page.findOne({ path: '/parentForDelete1' });
+    parentForDelete2 = await Page.findOne({ path: '/parentForDelete2' });
+
+    parentForDeleteCompletely = await Page.findOne({ path: '/parentForDeleteCompletely' });
+    parentForRevert1 = await Page.findOne({ path: '/trash/parentForRevert1' });
+    parentForRevert2 = await Page.findOne({ path: '/trash/parentForRevert2' });
+
+    childForRename1 = await Page.findOne({ path: '/parentForRename1/child' });
+    childForRename2 = await Page.findOne({ path: '/parentForRename2/child' });
+    childForRename3 = await Page.findOne({ path: '/parentForRename3/child' });
+
+    childForDuplicate = await Page.findOne({ path: '/parentForDuplicate/child' });
+    childForDelete = await Page.findOne({ path: '/parentForDelete/child' });
+    childForDeleteCompletely = await Page.findOne({ path: '/parentForDeleteCompletely/child' });
+    childForRevert = await Page.findOne({ path: '/trash/parentForRevert/child' });
+
+
+    await Tag.insertMany([
+      { name: 'Parent' },
+      { name: 'Child' },
+    ]);
+
+    parentTag = await Tag.findOne({ name: 'Parent' });
+    childTag = await Tag.findOne({ name: 'Child' });
+
+    await PageTagRelation.insertMany([
+      { relatedPage: parentForDuplicate, relatedTag: parentTag },
+      { relatedPage: childForDuplicate, relatedTag: childTag },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: '600d395667536503354cbe91',
+        path: parentForDuplicate.path,
+        body: 'duplicateBody',
+      },
+      {
+        _id: '600d395667536503354cbe92',
+        path: childForDuplicate.path,
+        body: 'duplicateChildBody',
+      },
+    ]);
+
+    xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
+
+
+    done();
+  });
+
+  describe('rename page', () => {
+    let pageEventSpy;
+    let renameDescendantsWithStreamSpy;
+    const dateToUse = new Date('2000-01-01');
+    const socketClientId = null;
+
+    beforeEach(async(done) => {
+      jest.spyOn(global.Date, 'now').mockImplementation(() => dateToUse);
+      pageEventSpy = jest.spyOn(crowi.pageService.pageEvent, 'emit').mockImplementation();
+      renameDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'renameDescendantsWithStream').mockImplementation();
+      done();
+    });
+
+    describe('renamePage()', () => {
+
+      test('rename page without options', async() => {
+
+        const resultPage = await crowi.pageService.renamePage(parentForRename1, '/renamed1', testUser2, {});
+        const redirectedFromPage = await Page.findOne({ path: '/parentForRename1' });
+        const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename1' });
+
+        expect(xssSpy).toHaveBeenCalled();
+        expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
+        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename1, testUser2, socketClientId);
+        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+
+        expect(resultPage.path).toBe('/renamed1');
+        expect(resultPage.updatedAt).toEqual(parentForRename1.updatedAt);
+        expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+        expect(redirectedFromPage).toBeNull();
+        expect(redirectedFromPageRevision).toBeNull();
+      });
+
+      test('rename page with updateMetadata option', async() => {
+
+        const resultPage = await crowi.pageService.renamePage(parentForRename2, '/renamed2', testUser2, { updateMetadata: true });
+        const redirectedFromPage = await Page.findOne({ path: '/parentForRename2' });
+        const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename2' });
+
+        expect(xssSpy).toHaveBeenCalled();
+        expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
+        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename2, testUser2, socketClientId);
+        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+
+        expect(resultPage.path).toBe('/renamed2');
+        expect(resultPage.updatedAt).toEqual(dateToUse);
+        expect(resultPage.lastUpdateUser).toEqual(testUser2._id);
+
+        expect(redirectedFromPage).toBeNull();
+        expect(redirectedFromPageRevision).toBeNull();
+      });
+
+      test('rename page with createRedirectPage option', async() => {
+
+        const resultPage = await crowi.pageService.renamePage(parentForRename3, '/renamed3', testUser2, { createRedirectPage: true });
+        const redirectedFromPage = await Page.findOne({ path: '/parentForRename3' });
+        const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename3' });
+
+        expect(xssSpy).toHaveBeenCalled();
+        expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
+        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename3, testUser2, socketClientId);
+        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+
+        expect(resultPage.path).toBe('/renamed3');
+        expect(resultPage.updatedAt).toEqual(parentForRename3.updatedAt);
+        expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+        expect(redirectedFromPage).not.toBeNull();
+        expect(redirectedFromPage.path).toBe('/parentForRename3');
+        expect(redirectedFromPage.redirectTo).toBe('/renamed3');
+
+        expect(redirectedFromPageRevision).not.toBeNull();
+        expect(redirectedFromPageRevision.path).toBe('/parentForRename3');
+        expect(redirectedFromPageRevision.body).toBe('redirect /renamed3');
+      });
+
+      test('rename page with isRecursively', async() => {
+
+        const resultPage = await crowi.pageService.renamePage(parentForRename4, '/renamed4', testUser2, { }, true);
+        const redirectedFromPage = await Page.findOne({ path: '/parentForRename4' });
+        const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename4' });
+
+        expect(xssSpy).toHaveBeenCalled();
+        expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
+        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename4, testUser2, socketClientId);
+        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+
+        expect(resultPage.path).toBe('/renamed4');
+        expect(resultPage.updatedAt).toEqual(parentForRename4.updatedAt);
+        expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+        expect(redirectedFromPage).toBeNull();
+        expect(redirectedFromPageRevision).toBeNull();
+      });
+
+    });
+
+    test('renameDescendants without options', async() => {
+      const oldPagePathPrefix = new RegExp('^/parentForRename1', 'i');
+      const newPagePathPrefix = '/renamed1';
+
+      await crowi.pageService.renameDescendants([childForRename1], testUser2, {}, oldPagePathPrefix, newPagePathPrefix);
+      const resultPage = await Page.findOne({ path: '/renamed1/child' });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForRename1/child' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename1/child' });
+
+      expect(resultPage).not.toBeNull();
+      expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename1], testUser2);
+
+      expect(resultPage.path).toBe('/renamed1/child');
+      expect(resultPage.updatedAt).toEqual(childForRename1.updatedAt);
+      expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+      expect(redirectedFromPage).toBeNull();
+      expect(redirectedFromPageRevision).toBeNull();
+    });
+
+    test('renameDescendants with updateMetadata option', async() => {
+      const oldPagePathPrefix = new RegExp('^/parentForRename2', 'i');
+      const newPagePathPrefix = '/renamed2';
+
+      await crowi.pageService.renameDescendants([childForRename2], testUser2, { updateMetadata: true }, oldPagePathPrefix, newPagePathPrefix);
+      const resultPage = await Page.findOne({ path: '/renamed2/child' });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForRename2/child' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename2/child' });
+
+      expect(resultPage).not.toBeNull();
+      expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename2], testUser2);
+
+      expect(resultPage.path).toBe('/renamed2/child');
+      expect(resultPage.updatedAt).toEqual(dateToUse);
+      expect(resultPage.lastUpdateUser).toEqual(testUser2._id);
+
+      expect(redirectedFromPage).toBeNull();
+      expect(redirectedFromPageRevision).toBeNull();
+    });
+
+    test('renameDescendants with createRedirectPage option', async() => {
+      const oldPagePathPrefix = new RegExp('^/parentForRename3', 'i');
+      const newPagePathPrefix = '/renamed3';
+
+      await crowi.pageService.renameDescendants([childForRename3], testUser2, { createRedirectPage: true }, oldPagePathPrefix, newPagePathPrefix);
+      const resultPage = await Page.findOne({ path: '/renamed3/child' });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForRename3/child' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForRename3/child' });
+
+      expect(resultPage).not.toBeNull();
+      expect(pageEventSpy).toHaveBeenCalledWith('updateMany', [childForRename3], testUser2);
+
+      expect(resultPage.path).toBe('/renamed3/child');
+      expect(resultPage.updatedAt).toEqual(childForRename3.updatedAt);
+      expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+      expect(redirectedFromPage).not.toBeNull();
+      expect(redirectedFromPage.path).toBe('/parentForRename3/child');
+      expect(redirectedFromPage.redirectTo).toBe('/renamed3/child');
+
+      expect(redirectedFromPageRevision).not.toBeNull();
+      expect(redirectedFromPageRevision.path).toBe('/parentForRename3/child');
+      expect(redirectedFromPageRevision.body).toBe('redirect /renamed3/child');
+    });
+  });
+
+
+  describe('duplicate page', () => {
+    let duplicateDescendantsWithStreamSpy;
+
+    jest.mock('../../server/models/serializers/page-serializer');
+    const { serializePageSecurely } = require('../../server/models/serializers/page-serializer');
+    serializePageSecurely.mockImplementation(page => page);
+
+    beforeEach(async(done) => {
+      duplicateDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'duplicateDescendantsWithStream').mockImplementation();
+      done();
+    });
+
+    test('duplicate page (isRecursively: false)', async() => {
+      const dummyId = '600d395667536503354c9999';
+      crowi.models.Page.findRelatedTagsById = jest.fn().mockImplementation(() => { return parentTag });
+      const originTagsMock = jest.spyOn(Page, 'findRelatedTagsById').mockImplementation(() => { return parentTag });
+      jest.spyOn(PageTagRelation, 'updatePageTags').mockImplementation(() => { return [dummyId, parentTag.name] });
+      jest.spyOn(PageTagRelation, 'listTagNamesByPage').mockImplementation(() => { return [parentTag.name] });
+
+      const resultPage = await crowi.pageService.duplicate(parentForDuplicate, '/newParentDuplicate', testUser2, false);
+      const duplicatedToPageRevision = await Revision.findOne({ path: '/newParentDuplicate' });
+
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicateDescendantsWithStreamSpy).not.toHaveBeenCalled();
+      expect(serializePageSecurely).toHaveBeenCalled();
+      expect(resultPage.path).toBe('/newParentDuplicate');
+      expect(resultPage.lastUpdateUser._id).toEqual(testUser2._id);
+      expect(duplicatedToPageRevision._id).not.toEqual(parentForDuplicate.revision._id);
+      expect(resultPage.grant).toEqual(parentForDuplicate.grant);
+      expect(resultPage.tags).toEqual([originTagsMock().name]);
+    });
+
+    test('duplicate page (isRecursively: true)', async() => {
+      const dummyId = '600d395667536503354c9999';
+      crowi.models.Page.findRelatedTagsById = jest.fn().mockImplementation(() => { return parentTag });
+      const originTagsMock = jest.spyOn(Page, 'findRelatedTagsById').mockImplementation(() => { return parentTag });
+      jest.spyOn(PageTagRelation, 'updatePageTags').mockImplementation(() => { return [dummyId, parentTag.name] });
+      jest.spyOn(PageTagRelation, 'listTagNamesByPage').mockImplementation(() => { return [parentTag.name] });
+
+      const resultPageRecursivly = await crowi.pageService.duplicate(parentForDuplicate, '/newParentDuplicateRecursively', testUser2, true);
+      const duplicatedRecursivelyToPageRevision = await Revision.findOne({ path: '/newParentDuplicateRecursively' });
+
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicateDescendantsWithStreamSpy).toHaveBeenCalled();
+      expect(serializePageSecurely).toHaveBeenCalled();
+      expect(resultPageRecursivly.path).toBe('/newParentDuplicateRecursively');
+      expect(resultPageRecursivly.lastUpdateUser._id).toEqual(testUser2._id);
+      expect(duplicatedRecursivelyToPageRevision._id).not.toEqual(parentForDuplicate.revision._id);
+      expect(resultPageRecursivly.grant).toEqual(parentForDuplicate.grant);
+      expect(resultPageRecursivly.tags).toEqual([originTagsMock().name]);
+    });
+
+    test('duplicateDescendants()', async() => {
+      const duplicateTagsMock = await jest.spyOn(crowi.pageService, 'duplicateTags').mockImplementationOnce();
+      await crowi.pageService.duplicateDescendants([childForDuplicate], testUser2, parentForDuplicate.path, '/newPathPrefix');
+
+      const childForDuplicateRevision = await Revision.findOne({ path: childForDuplicate.path });
+      const insertedPage = await Page.findOne({ path: '/newPathPrefix/child' });
+      const insertedRevision = await Revision.findOne({ path: '/newPathPrefix/child' });
+
+      expect(insertedPage).not.toBeNull();
+      expect(insertedPage.path).toEqual('/newPathPrefix/child');
+      expect(insertedPage.lastUpdateUser).toEqual(testUser2._id);
+
+      expect([insertedRevision]).not.toBeNull();
+      expect(insertedRevision.path).toEqual('/newPathPrefix/child');
+      expect(insertedRevision._id).not.toEqual(childForDuplicateRevision._id);
+      expect(insertedRevision.body).toEqual(childForDuplicateRevision.body);
+
+      expect(duplicateTagsMock).toHaveBeenCalled();
+    });
+
+    test('duplicateTags()', async() => {
+      const pageIdMapping = {
+        [parentForDuplicate._id]: '60110bdd85339d7dc732dddd',
+      };
+      const duplicateTagsReturn = await crowi.pageService.duplicateTags(pageIdMapping);
+      const parentoForDuplicateTag = await PageTagRelation.findOne({ relatedPage: parentForDuplicate._id });
+      expect(duplicateTagsReturn).toHaveLength(1);
+      expect(duplicateTagsReturn[0].relatedTag).toEqual(parentoForDuplicateTag.relatedTag);
+    });
+  });
+
+  describe('delete page', () => {
+    let getDeletedPageNameSpy;
+    let pageEventSpy;
+    let deleteDescendantsWithStreamSpy;
+    const dateToUse = new Date('2000-01-01');
+    const socketClientId = null;
+
+    beforeEach(async(done) => {
+      jest.spyOn(global.Date, 'now').mockImplementation(() => dateToUse);
+      getDeletedPageNameSpy = jest.spyOn(Page, 'getDeletedPageName');
+      pageEventSpy = jest.spyOn(crowi.pageService.pageEvent, 'emit');
+      deleteDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'deleteDescendantsWithStream').mockImplementation();
+      done();
+    });
+
+    test('delete page without options', async() => {
+      const resultPage = await crowi.pageService.deletePage(parentForDelete1, testUser2, { });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForDelete1' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForDelete1' });
+
+      expect(getDeletedPageNameSpy).toHaveBeenCalled();
+      expect(deleteDescendantsWithStreamSpy).not.toHaveBeenCalled();
+
+      expect(resultPage.status).toBe(Page.STATUS_DELETED);
+      expect(resultPage.path).toBe('/trash/parentForDelete1');
+      expect(resultPage.deleteUser).toEqual(testUser2._id);
+      expect(resultPage.deletedAt).toEqual(dateToUse);
+      expect(resultPage.updatedAt).toEqual(parentForDelete1.updatedAt);
+      expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+      expect(redirectedFromPage).not.toBeNull();
+      expect(redirectedFromPage.path).toBe('/parentForDelete1');
+      expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete1');
+
+      expect(redirectedFromPageRevision).not.toBeNull();
+      expect(redirectedFromPageRevision.path).toBe('/parentForDelete1');
+      expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete1');
+
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete1, testUser2, socketClientId);
+      expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+
+    });
+
+    test('delete page with isRecursively', async() => {
+      const resultPage = await crowi.pageService.deletePage(parentForDelete2, testUser2, { }, true);
+      const redirectedFromPage = await Page.findOne({ path: '/parentForDelete2' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForDelete2' });
+
+      expect(getDeletedPageNameSpy).toHaveBeenCalled();
+      expect(deleteDescendantsWithStreamSpy).toHaveBeenCalled();
+
+      expect(resultPage.status).toBe(Page.STATUS_DELETED);
+      expect(resultPage.path).toBe('/trash/parentForDelete2');
+      expect(resultPage.deleteUser).toEqual(testUser2._id);
+      expect(resultPage.deletedAt).toEqual(dateToUse);
+      expect(resultPage.updatedAt).toEqual(parentForDelete2.updatedAt);
+      expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+      expect(redirectedFromPage).not.toBeNull();
+      expect(redirectedFromPage.path).toBe('/parentForDelete2');
+      expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete2');
+
+      expect(redirectedFromPageRevision).not.toBeNull();
+      expect(redirectedFromPageRevision.path).toBe('/parentForDelete2');
+      expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete2');
+
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete2, testUser2, socketClientId);
+      expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+
+    });
+
+
+    test('deleteDescendants', async() => {
+      await crowi.pageService.deleteDescendants([childForDelete], testUser2);
+      const resultPage = await Page.findOne({ path: '/trash/parentForDelete/child' });
+      const redirectedFromPage = await Page.findOne({ path: '/parentForDelete/child' });
+      const redirectedFromPageRevision = await Revision.findOne({ path: '/parentForDelete/child' });
+
+      expect(resultPage.status).toBe(Page.STATUS_DELETED);
+      expect(resultPage.path).toBe('/trash/parentForDelete/child');
+      expect(resultPage.deleteUser).toEqual(testUser2._id);
+      expect(resultPage.deletedAt).toEqual(dateToUse);
+      expect(resultPage.updatedAt).toEqual(childForDelete.updatedAt);
+      expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
+
+      expect(redirectedFromPage).not.toBeNull();
+      expect(redirectedFromPage.path).toBe('/parentForDelete/child');
+      expect(redirectedFromPage.redirectTo).toBe('/trash/parentForDelete/child');
+
+      expect(redirectedFromPageRevision).not.toBeNull();
+      expect(redirectedFromPageRevision.path).toBe('/parentForDelete/child');
+      expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete/child');
+    });
+  });
+
+  describe('delete page completely', () => {
+    let pageEventSpy;
+    let deleteCompletelyOperationSpy;
+    let deleteCompletelyDescendantsWithStreamSpy;
+    const socketClientId = null;
+
+    let deleteManyBookmarkSpy;
+    let deleteManyCommentSpy;
+    let deleteManyPageTagRelationSpy;
+    let deleteManyShareLinkSpy;
+    let deleteManyRevisionSpy;
+    let deleteManyPageSpy;
+    let removeAllAttachmentsSpy;
+
+    beforeEach(async(done) => {
+      pageEventSpy = jest.spyOn(crowi.pageService.pageEvent, 'emit');
+      deleteCompletelyOperationSpy = jest.spyOn(crowi.pageService, 'deleteCompletelyOperation');
+      deleteCompletelyDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'deleteCompletelyDescendantsWithStream').mockImplementation();
+
+      deleteManyBookmarkSpy = jest.spyOn(Bookmark, 'deleteMany').mockImplementation();
+      deleteManyCommentSpy = jest.spyOn(Comment, 'deleteMany').mockImplementation();
+      deleteManyPageTagRelationSpy = jest.spyOn(PageTagRelation, 'deleteMany').mockImplementation();
+      deleteManyShareLinkSpy = jest.spyOn(ShareLink, 'deleteMany').mockImplementation();
+      deleteManyRevisionSpy = jest.spyOn(Revision, 'deleteMany').mockImplementation();
+      deleteManyPageSpy = jest.spyOn(Page, 'deleteMany').mockImplementation();
+      removeAllAttachmentsSpy = jest.spyOn(crowi.attachmentService, 'removeAllAttachments').mockImplementation();
+      done();
+    });
+    test('deleteCompletelyOperation', async() => {
+      await crowi.pageService.deleteCompletelyOperation([parentForDeleteCompletely._id], [parentForDeleteCompletely.path], { });
+
+      expect(deleteManyBookmarkSpy).toHaveBeenCalledWith({ page: { $in: [parentForDeleteCompletely._id] } });
+      expect(deleteManyCommentSpy).toHaveBeenCalledWith({ page: { $in: [parentForDeleteCompletely._id] } });
+      expect(deleteManyPageTagRelationSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
+      expect(deleteManyShareLinkSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
+      expect(deleteManyRevisionSpy).toHaveBeenCalledWith({ path: { $in: [parentForDeleteCompletely.path] } });
+      expect(deleteManyPageSpy).toHaveBeenCalledWith({
+        $or: [{ path: { $in: [parentForDeleteCompletely.path] } },
+              { path: { $in: [] } },
+              { _id: { $in: [parentForDeleteCompletely._id] } }],
+      });
+      expect(removeAllAttachmentsSpy).toHaveBeenCalled();
+    });
+
+    test('delete completely without options', async() => {
+      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { });
+
+      expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
+      expect(deleteCompletelyDescendantsWithStreamSpy).not.toHaveBeenCalled();
+
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDeleteCompletely, testUser2, socketClientId);
+    });
+
+
+    test('delete completely with isRecursively', async() => {
+      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, true);
+
+      expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
+      expect(deleteCompletelyDescendantsWithStreamSpy).toHaveBeenCalled();
+
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDeleteCompletely, testUser2, socketClientId);
+    });
+  });
+
+  describe('revert page', () => {
+    let getRevertDeletedPageNameSpy;
+    let findByPathSpy;
+    let findSpy;
+    let deleteCompletelySpy;
+    let revertDeletedDescendantsWithStreamSpy;
+
+    beforeEach(async(done) => {
+      getRevertDeletedPageNameSpy = jest.spyOn(Page, 'getRevertDeletedPageName');
+      deleteCompletelySpy = jest.spyOn(crowi.pageService, 'deleteCompletely').mockImplementation();
+      revertDeletedDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'revertDeletedDescendantsWithStream').mockImplementation();
+      done();
+    });
+
+    test('revert deleted page when the redirect from page exists', async() => {
+
+      findByPathSpy = jest.spyOn(Page, 'findByPath').mockImplementation(() => {
+        return { redirectTo: '/trash/parentForRevert1' };
+      });
+
+      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert1, testUser2);
+
+      expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(parentForRevert1.path);
+      expect(findByPathSpy).toHaveBeenCalledWith('/parentForRevert1');
+      expect(deleteCompletelySpy).toHaveBeenCalled();
+      expect(revertDeletedDescendantsWithStreamSpy).not.toHaveBeenCalled();
+
+      expect(resultPage.path).toBe('/parentForRevert1');
+      expect(resultPage.lastUpdateUser._id).toEqual(testUser2._id);
+      expect(resultPage.status).toBe(Page.STATUS_PUBLISHED);
+      expect(resultPage.deleteUser).toBeNull();
+      expect(resultPage.deletedAt).toBeNull();
+    });
+
+    test('revert deleted page when the redirect from page does not exist', async() => {
+
+      findByPathSpy = jest.spyOn(Page, 'findByPath').mockImplementation(() => {
+        return null;
+      });
+
+      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert2, testUser2, {}, true);
+
+      expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(parentForRevert2.path);
+      expect(findByPathSpy).toHaveBeenCalledWith('/parentForRevert2');
+      expect(deleteCompletelySpy).not.toHaveBeenCalled();
+      expect(revertDeletedDescendantsWithStreamSpy).toHaveBeenCalled();
+
+      expect(resultPage.path).toBe('/parentForRevert2');
+      expect(resultPage.lastUpdateUser._id).toEqual(testUser2._id);
+      expect(resultPage.status).toBe(Page.STATUS_PUBLISHED);
+      expect(resultPage.deleteUser).toBeNull();
+      expect(resultPage.deletedAt).toBeNull();
+    });
+
+    test('revert deleted descendants', async() => {
+
+      findSpy = jest.spyOn(Page, 'find').mockImplementation(() => {
+        return [{ path: '/parentForRevert/child', redirectTo: '/trash/parentForRevert/child' }];
+      });
+
+      await crowi.pageService.revertDeletedDescendants([childForRevert], testUser2);
+      const resultPage = await Page.findOne({ path: '/parentForRevert/child' });
+      const revrtedFromPage = await Page.findOne({ path: '/trash/parentForRevert/child' });
+      const revrtedFromPageRevision = await Revision.findOne({ path: '/trash/parentForRevert/child' });
+
+      expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(childForRevert.path);
+      expect(findSpy).toHaveBeenCalledWith({ path: { $in: ['/parentForRevert/child'] } });
+
+      expect(resultPage.path).toBe('/parentForRevert/child');
+      expect(resultPage.lastUpdateUser._id).toEqual(testUser2._id);
+      expect(resultPage.status).toBe(Page.STATUS_PUBLISHED);
+      expect(resultPage.deleteUser).toBeNull();
+      expect(resultPage.deletedAt).toBeNull();
+
+      expect(revrtedFromPage).toBeNull();
+      expect(revrtedFromPageRevision).toBeNull();
+    });
+  });
+
+
+});

+ 208 - 35
yarn.lock

@@ -1761,6 +1761,39 @@
   dependencies:
   dependencies:
     mkdirp "^1.0.4"
     mkdirp "^1.0.4"
 
 
+"@promster/express@^5.0.1":
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/@promster/express/-/express-5.0.1.tgz#192a37cf04a1cf59c5aac6332d53508ddd6de5e5"
+  integrity sha512-o8Uk9K9ZAAT4vwr2LRKETJGzgjw02WbkhcBgtUp2Ep8qRXdkNo7/Hl5NDBw6v5bIod/La/vSq6gUdSPPVWO2dw==
+  dependencies:
+    "@promster/metrics" "^6.0.0"
+    merge-options "3.0.3"
+    tslib "2.0.3"
+
+"@promster/metrics@^6.0.0":
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/@promster/metrics/-/metrics-6.0.0.tgz#7aa33934f00e4c00dcc39de18772e98c2bd485ae"
+  integrity sha512-+TzrQE/nlKIBWjmBKilOSoR6vM0fYqZVEILRFo1EkrRKUWKeqP+DxOGF6hnxv3VZQP0oLYXSnHgIyKsB0EOpGA==
+  dependencies:
+    lodash.memoize "4.1.2"
+    lodash.once "4.1.1"
+    merge-options "3.0.3"
+    optional "0.1.4"
+    ts-essentials "7.0.1"
+    tslib "2.0.3"
+    url "0.11.0"
+    url-value-parser "2.0.1"
+  optionalDependencies:
+    gc-stats "1.4.0"
+
+"@promster/server@^6.0.0":
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/@promster/server/-/server-6.0.0.tgz#f3cbbb33ec72690bbe23bdd1088490f81a35e484"
+  integrity sha512-1pc3sy3Y9cOpTBZVEeXMDMTD1CvnztvLn2Y21SiIUSyy2PnABL/eIcYRM304xKdRvV8fDmTJkNY4fX+bfdlKHA==
+  dependencies:
+    "@promster/metrics" "^6.0.0"
+    tslib "2.0.3"
+
 "@sindresorhus/is@^0.7.0":
 "@sindresorhus/is@^0.7.0":
   version "0.7.0"
   version "0.7.0"
   resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd"
   resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd"
@@ -2961,14 +2994,6 @@ axios@0.17.1:
     follow-redirects "^1.2.5"
     follow-redirects "^1.2.5"
     is-buffer "^1.1.5"
     is-buffer "^1.1.5"
 
 
-axios@^0.19.0:
-  version "0.19.0"
-  resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.0.tgz#8e09bff3d9122e133f7b8101c8fbdd00ed3d2ab8"
-  integrity sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==
-  dependencies:
-    follow-redirects "1.5.10"
-    is-buffer "^2.0.2"
-
 axios@^0.21.1:
 axios@^0.21.1:
   version "0.21.1"
   version "0.21.1"
   resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
   resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
@@ -3260,6 +3285,11 @@ bindings@^1.5.0:
   dependencies:
   dependencies:
     file-uri-to-path "1.0.0"
     file-uri-to-path "1.0.0"
 
 
+bintrees@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.1.tgz#0e655c9b9c2435eaab68bf4027226d2b55a34524"
+  integrity sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ=
+
 bl@^2.2.1:
 bl@^2.2.1:
   version "2.2.1"
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/bl/-/bl-2.2.1.tgz#8c11a7b730655c5d56898cdc871224f40fd901d5"
   resolved "https://registry.yarnpkg.com/bl/-/bl-2.2.1.tgz#8c11a7b730655c5d56898cdc871224f40fd901d5"
@@ -5012,7 +5042,7 @@ debug@2, debug@2.6.9, debug@^2.0.0, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, de
   dependencies:
   dependencies:
     ms "2.0.0"
     ms "2.0.0"
 
 
-debug@3.1.0, debug@=3.1.0, debug@~3.1.0:
+debug@3.1.0, debug@~3.1.0:
   version "3.1.0"
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
   integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
   integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
@@ -5148,6 +5178,11 @@ detect-indent@^5.0.0:
   resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d"
   resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d"
   integrity sha1-OHHMCmoALow+Wzz38zYmRnXwa50=
   integrity sha1-OHHMCmoALow+Wzz38zYmRnXwa50=
 
 
+detect-libc@^1.0.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
+  integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=
+
 detect-newline@^3.0.0:
 detect-newline@^3.0.0:
   version "3.1.0"
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
   resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
@@ -6735,13 +6770,6 @@ focus-trap@^2.4.5:
   dependencies:
   dependencies:
     tabbable "^1.0.3"
     tabbable "^1.0.3"
 
 
-follow-redirects@1.5.10:
-  version "1.5.10"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a"
-  integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==
-  dependencies:
-    debug "=3.1.0"
-
 follow-redirects@^1.10.0:
 follow-redirects@^1.10.0:
   version "1.13.1"
   version "1.13.1"
   resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.1.tgz#5f69b813376cee4fd0474a3aba835df04ab763b7"
   resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.1.tgz#5f69b813376cee4fd0474a3aba835df04ab763b7"
@@ -6872,6 +6900,13 @@ fs-extra@^9.0.1:
     jsonfile "^6.0.1"
     jsonfile "^6.0.1"
     universalify "^1.0.0"
     universalify "^1.0.0"
 
 
+fs-minipass@^1.2.5:
+  version "1.2.7"
+  resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7"
+  integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==
+  dependencies:
+    minipass "^2.6.0"
+
 fs-minipass@^2.0.0:
 fs-minipass@^2.0.0:
   version "2.1.0"
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb"
   resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb"
@@ -6962,6 +6997,14 @@ gaze@^1.0.0:
   dependencies:
   dependencies:
     globule "^1.0.0"
     globule "^1.0.0"
 
 
+gc-stats@1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/gc-stats/-/gc-stats-1.4.0.tgz#66cd194c5a8eae1138407300bc6cb42c2f6f3cd6"
+  integrity sha512-4FcCj9e8j8rCjvLkqRpGZBLgTC/xr9XEf5By3x77cDucWWB3pJK6FEwXZCTCbb4z8xdaOoi4owBNrvn3ciDdxA==
+  dependencies:
+    nan "^2.13.2"
+    node-pre-gyp "^0.13.0"
+
 gcp-metadata@^3.0.0:
 gcp-metadata@^3.0.0:
   version "3.0.0"
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-3.0.0.tgz#6e43f899728d0e1bb7831631cf3f86cc9e4de3aa"
   resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-3.0.0.tgz#6e43f899728d0e1bb7831631cf3f86cc9e4de3aa"
@@ -7761,7 +7804,7 @@ iconv-lite@0.4.23:
   dependencies:
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
     safer-buffer ">= 2.1.2 < 3"
 
 
-iconv-lite@0.4.24, iconv-lite@^0.4.24:
+iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4:
   version "0.4.24"
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
   dependencies:
   dependencies:
@@ -7786,6 +7829,13 @@ iferr@^0.1.5:
   version "0.1.5"
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501"
   resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501"
 
 
+ignore-walk@^3.0.1:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.3.tgz#017e2447184bfeade7c238e4aefdd1e8f95b1e37"
+  integrity sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==
+  dependencies:
+    minimatch "^3.0.4"
+
 ignore@^4.0.6:
 ignore@^4.0.6:
   version "4.0.6"
   version "4.0.6"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
@@ -8051,11 +8101,6 @@ is-buffer@^2.0.0, is-buffer@~2.0.4:
   resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.4.tgz#3e572f23c8411a5cfd9557c849e3665e0b290623"
   resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.4.tgz#3e572f23c8411a5cfd9557c849e3665e0b290623"
   integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==
   integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==
 
 
-is-buffer@^2.0.2:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.3.tgz#4ecf3fcf749cbd1e472689e109ac66261a25e725"
-  integrity sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==
-
 is-callable@^1.1.1, is-callable@^1.1.3:
 is-callable@^1.1.1, is-callable@^1.1.3:
   version "1.1.3"
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.3.tgz#86eb75392805ddc33af71c92a0eedf74ee7604b2"
   resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.3.tgz#86eb75392805ddc33af71c92a0eedf74ee7604b2"
@@ -8280,6 +8325,11 @@ is-plain-obj@^1.0.0, is-plain-obj@^1.1.0:
   version "1.1.0"
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
   resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
 
 
+is-plain-obj@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
+  integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
+
 is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4:
 is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4:
   version "2.0.4"
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
   resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
@@ -9472,11 +9522,11 @@ lodash.isstring@^4.0.1:
   resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
   resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
   integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=
   integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=
 
 
-lodash.memoize@^4.1.2:
+lodash.memoize@4.1.2, lodash.memoize@^4.1.2:
   version "4.1.2"
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
 
 
-lodash.once@^4.0.0:
+lodash.once@4.1.1, lodash.once@^4.0.0:
   version "4.1.1"
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
   resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
   integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=
   integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=
@@ -9902,6 +9952,13 @@ merge-descriptors@1.0.1:
   version "1.0.1"
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
   resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
 
 
+merge-options@3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/merge-options/-/merge-options-3.0.3.tgz#802b401f0de8dfae00d2a1e2dab9759b3dd98fe4"
+  integrity sha512-jytfjQxL5mVrtD9O24zOXU4neV3uVbQdn1F0o1pzSa1yH9LTEUOtfwpWSsyAxrrrXqAFTxaU4ynqkmekHLvYew==
+  dependencies:
+    is-plain-obj "^2.1.0"
+
 merge-stream@^2.0.0:
 merge-stream@^2.0.0:
   version "2.0.0"
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
   resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@@ -10175,6 +10232,14 @@ minipass-pipeline@^1.2.2:
   dependencies:
   dependencies:
     minipass "^3.0.0"
     minipass "^3.0.0"
 
 
+minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0:
+  version "2.9.0"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6"
+  integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==
+  dependencies:
+    safe-buffer "^5.1.2"
+    yallist "^3.0.0"
+
 minipass@^3.0.0, minipass@^3.1.1:
 minipass@^3.0.0, minipass@^3.1.1:
   version "3.1.3"
   version "3.1.3"
   resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd"
   resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd"
@@ -10182,6 +10247,13 @@ minipass@^3.0.0, minipass@^3.1.1:
   dependencies:
   dependencies:
     yallist "^4.0.0"
     yallist "^4.0.0"
 
 
+minizlib@^1.2.1:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d"
+  integrity sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==
+  dependencies:
+    minipass "^2.9.0"
+
 minizlib@^2.1.1:
 minizlib@^2.1.1:
   version "2.1.2"
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
   resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
@@ -10447,6 +10519,15 @@ ncp@~2.0.0:
   version "2.0.0"
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
   resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
 
 
+needle@^2.2.1:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/needle/-/needle-2.6.0.tgz#24dbb55f2509e2324b4a99d61f413982013ccdbe"
+  integrity sha512-KKYdza4heMsEfSWD7VPUIz3zX2XDwOyX2d+geb4vrERZMT5RMU6ujjaD+I5Yr54uZxQ2w6XRTAhHBbSCyovZBg==
+  dependencies:
+    debug "^3.2.6"
+    iconv-lite "^0.4.4"
+    sax "^1.2.4"
+
 negotiator@0.6.1:
 negotiator@0.6.1:
   version "0.6.1"
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9"
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9"
@@ -10615,6 +10696,22 @@ node-object-hash@^1.2.0:
   resolved "https://registry.yarnpkg.com/node-object-hash/-/node-object-hash-1.4.2.tgz#385833d85b229902b75826224f6077be969a9e94"
   resolved "https://registry.yarnpkg.com/node-object-hash/-/node-object-hash-1.4.2.tgz#385833d85b229902b75826224f6077be969a9e94"
   integrity sha512-UdS4swXs85fCGWWf6t6DMGgpN/vnlKeSGEQ7hJcrs7PBFoxoKLmibc3QRb7fwiYsjdL7PX8iI/TMSlZ90dgHhQ==
   integrity sha512-UdS4swXs85fCGWWf6t6DMGgpN/vnlKeSGEQ7hJcrs7PBFoxoKLmibc3QRb7fwiYsjdL7PX8iI/TMSlZ90dgHhQ==
 
 
+node-pre-gyp@^0.13.0:
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.13.0.tgz#df9ab7b68dd6498137717838e4f92a33fc9daa42"
+  integrity sha512-Md1D3xnEne8b/HGVQkZZwV27WUi1ZRuZBij24TNaZwUPU3ZAFtvT6xxJGaUVillfmMKnn5oD1HoGsp2Ftik7SQ==
+  dependencies:
+    detect-libc "^1.0.2"
+    mkdirp "^0.5.1"
+    needle "^2.2.1"
+    nopt "^4.0.1"
+    npm-packlist "^1.1.6"
+    npmlog "^4.0.2"
+    rc "^1.2.7"
+    rimraf "^2.6.1"
+    semver "^5.3.0"
+    tar "^4"
+
 node-readfiles@^0.2.0:
 node-readfiles@^0.2.0:
   version "0.2.0"
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/node-readfiles/-/node-readfiles-0.2.0.tgz#dbbd4af12134e2e635c245ef93ffcf6f60673a5d"
   resolved "https://registry.yarnpkg.com/node-readfiles/-/node-readfiles-0.2.0.tgz#dbbd4af12134e2e635c245ef93ffcf6f60673a5d"
@@ -10693,6 +10790,14 @@ nopt@1.0.10:
   dependencies:
   dependencies:
     abbrev "1"
     abbrev "1"
 
 
+nopt@^4.0.1:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.3.tgz#a375cad9d02fd921278d954c2254d5aa57e15e48"
+  integrity sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==
+  dependencies:
+    abbrev "1"
+    osenv "^0.1.4"
+
 normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.5.0:
 normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.5.0:
   version "2.5.0"
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
   resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
@@ -10745,6 +10850,27 @@ normalize-url@^3.0.0:
   version "3.2.0"
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.2.0.tgz#98d0948afc82829f374320f405fe9ca55a5f8567"
   resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.2.0.tgz#98d0948afc82829f374320f405fe9ca55a5f8567"
 
 
+npm-bundled@^1.0.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b"
+  integrity sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA==
+  dependencies:
+    npm-normalize-package-bin "^1.0.1"
+
+npm-normalize-package-bin@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2"
+  integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==
+
+npm-packlist@^1.1.6:
+  version "1.4.8"
+  resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.8.tgz#56ee6cc135b9f98ad3d51c1c95da22bbb9b2ef3e"
+  integrity sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==
+  dependencies:
+    ignore-walk "^3.0.1"
+    npm-bundled "^1.0.1"
+    npm-normalize-package-bin "^1.0.1"
+
 npm-run-all@^4.1.2:
 npm-run-all@^4.1.2:
   version "4.1.2"
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/npm-run-all/-/npm-run-all-4.1.2.tgz#90d62d078792d20669139e718621186656cea056"
   resolved "https://registry.yarnpkg.com/npm-run-all/-/npm-run-all-4.1.2.tgz#90d62d078792d20669139e718621186656cea056"
@@ -10772,7 +10898,7 @@ npm-run-path@^4.0.0:
   dependencies:
   dependencies:
     path-key "^3.0.0"
     path-key "^3.0.0"
 
 
-"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0:
+"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0, npmlog@^4.0.2:
   version "4.1.2"
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
   resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
   integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
   integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
@@ -11128,6 +11254,11 @@ optimize-css-assets-webpack-plugin@^5.0.3:
     cssnano "^4.1.10"
     cssnano "^4.1.10"
     last-call-webpack-plugin "^3.0.0"
     last-call-webpack-plugin "^3.0.0"
 
 
+optional@0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/optional/-/optional-0.1.4.tgz#cdb1a9bedc737d2025f690ceeb50e049444fd5b3"
+  integrity sha512-gtvrrCfkE08wKcgXaVwQVgwEQ8vel2dc5DDBn9RLQZ3YtmtkBss6A2HY6BnJH4N/4Ku97Ri/SF8sNWE2225WJw==
+
 optionator@^0.8.1, optionator@^0.8.2:
 optionator@^0.8.1, optionator@^0.8.2:
   version "0.8.2"
   version "0.8.2"
   resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64"
   resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64"
@@ -11167,7 +11298,7 @@ os-tmpdir@^1.0.0, os-tmpdir@~1.0.2:
   version "1.0.2"
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
   resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
 
 
-osenv@0:
+osenv@0, osenv@^0.1.4:
   version "0.1.5"
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
   resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
   integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==
   integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==
@@ -12291,6 +12422,13 @@ progress@^2.0.0:
   version "2.0.0"
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f"
   resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f"
 
 
+prom-client@^13.0.0:
+  version "13.0.0"
+  resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-13.0.0.tgz#7e883e1c10c2d471258d3acf2d386197ffc819bd"
+  integrity sha512-M7ZNjIO6x+2R/vjSD13yjJPjpoZA8eEwH2Bp2Re0/PvzozD7azikv+SaBtZes4Q1ca/xHjZ4RSCuTag3YZLg1A==
+  dependencies:
+    tdigest "^0.1.1"
+
 promise-inflight@^1.0.1:
 promise-inflight@^1.0.1:
   version "1.0.1"
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
   resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
@@ -12604,7 +12742,7 @@ raw-body@^2.3.3:
     iconv-lite "0.4.24"
     iconv-lite "0.4.24"
     unpipe "1.0.0"
     unpipe "1.0.0"
 
 
-rc@>=1.2.8:
+rc@>=1.2.8, rc@^1.2.7:
   version "1.2.8"
   version "1.2.8"
   resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
   resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
   integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
   integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
@@ -13547,7 +13685,7 @@ right-align@^0.1.1:
   dependencies:
   dependencies:
     align-text "^0.1.1"
     align-text "^0.1.1"
 
 
-rimraf@2, rimraf@^2.6.2:
+rimraf@2, rimraf@^2.6.1, rimraf@^2.6.2:
   version "2.7.1"
   version "2.7.1"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
   integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
   integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
@@ -13730,7 +13868,7 @@ sax@1.2.1:
   version "1.2.1"
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a"
 
 
-sax@>=0.6.0, sax@^1.2.1, sax@~1.2.4:
+sax@>=0.6.0, sax@^1.2.1, sax@^1.2.4, sax@~1.2.4:
   version "1.2.4"
   version "1.2.4"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
   integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
   integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
@@ -15161,6 +15299,19 @@ tar@^2.0.0:
     fstream "^1.0.12"
     fstream "^1.0.12"
     inherits "2"
     inherits "2"
 
 
+tar@^4:
+  version "4.4.13"
+  resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525"
+  integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==
+  dependencies:
+    chownr "^1.1.1"
+    fs-minipass "^1.2.5"
+    minipass "^2.8.6"
+    minizlib "^1.2.1"
+    mkdirp "^0.5.0"
+    safe-buffer "^5.1.2"
+    yallist "^3.0.3"
+
 tar@^6.0.2:
 tar@^6.0.2:
   version "6.0.5"
   version "6.0.5"
   resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.5.tgz#bde815086e10b39f1dcd298e89d596e1535e200f"
   resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.5.tgz#bde815086e10b39f1dcd298e89d596e1535e200f"
@@ -15173,6 +15324,13 @@ tar@^6.0.2:
     mkdirp "^1.0.3"
     mkdirp "^1.0.3"
     yallist "^4.0.0"
     yallist "^4.0.0"
 
 
+tdigest@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.1.tgz#2e3cb2c39ea449e55d1e6cd91117accca4588021"
+  integrity sha1-Ljyyw56kSeVdHmzZEReszKRYgCE=
+  dependencies:
+    bintrees "1.0.1"
+
 teeny-request@^5.2.1:
 teeny-request@^5.2.1:
   version "5.2.1"
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/teeny-request/-/teeny-request-5.2.1.tgz#a6394db8359b87e64e47eeb2fbf34a65c9a751ff"
   resolved "https://registry.yarnpkg.com/teeny-request/-/teeny-request-5.2.1.tgz#a6394db8359b87e64e47eeb2fbf34a65c9a751ff"
@@ -15455,6 +15613,16 @@ tryer@^1.0.0:
   version "1.0.1"
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8"
   resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8"
 
 
+ts-essentials@7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-7.0.1.tgz#d205508cae0cdadfb73c89503140cf2228389e2d"
+  integrity sha512-8lwh3QJtIc1UWhkQtr9XuksXu3O0YQdEE5g79guDfhCaU1FWTDIEDZ1ZSx4HTHUmlJZ8L812j3BZQ4a0aOUkSA==
+
+tslib@2.0.3, tslib@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c"
+  integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ==
+
 tslib@^1.8.1:
 tslib@^1.8.1:
   version "1.10.0"
   version "1.10.0"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
@@ -15470,11 +15638,6 @@ tslib@^1.9.3:
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35"
   integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==
   integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==
 
 
-tslib@^2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c"
-  integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ==
-
 tsscmp@1.0.6, tsscmp@^1.0.6:
 tsscmp@1.0.6, tsscmp@^1.0.6:
   version "1.0.6"
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
   resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
@@ -15839,6 +16002,11 @@ url-to-options@^1.0.1:
   resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9"
   resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9"
   integrity sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k=
   integrity sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k=
 
 
+url-value-parser@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/url-value-parser/-/url-value-parser-2.0.1.tgz#c8179a095ab9ec1f5aa17ca36af5af396b4e95ed"
+  integrity sha512-bexECeREBIueboLGM3Y1WaAzQkIn+Tca/Xjmjmfd0S/hFHSCEoFkNh0/D0l9G4K74MkEP/lLFRlYnxX3d68Qgw==
+
 url@0.10.3:
 url@0.10.3:
   version "0.10.3"
   version "0.10.3"
   resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64"
   resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64"
@@ -15846,7 +16014,7 @@ url@0.10.3:
     punycode "1.3.2"
     punycode "1.3.2"
     querystring "0.2.0"
     querystring "0.2.0"
 
 
-url@^0.11.0:
+url@0.11.0, url@^0.11.0:
   version "0.11.0"
   version "0.11.0"
   resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
   resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
   dependencies:
   dependencies:
@@ -16528,6 +16696,11 @@ yallist@^2.1.2:
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
   integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
   integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
 
 
+yallist@^3.0.0, yallist@^3.0.3:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
+  integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
+
 yallist@^3.0.2:
 yallist@^3.0.2:
   version "3.0.2"
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.2.tgz#8452b4bb7e83c7c188d8041c1a837c773d6d8bb9"
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.2.tgz#8452b4bb7e83c7c188d8041c1a837c773d6d8bb9"