Sfoglia il codice sorgente

Merge branch 'master' into indent-size-changable

Yuki Takei 5 anni fa
parent
commit
f9d190d8ec
61 ha cambiato i file con 658 aggiunte e 458 eliminazioni
  1. 18 0
      .github/workflows/ci.yml
  2. 27 2
      CHANGES.md
  3. 0 1
      Procfile
  4. 1 1
      README.md
  5. 1 1
      bin/github-actions/list-branches.js
  6. 0 3
      bin/heroku/install-packages.sh
  7. 4 1
      package.json
  8. 4 0
      resource/Contributor.js
  9. 3 3
      resource/locales/en_US/translation.json
  10. 3 3
      resource/locales/ja_JP/translation.json
  11. 3 3
      resource/locales/zh_CN/translation.json
  12. 26 4
      src/client/js/components/Navbar/PageEditorModeManager.jsx
  13. 2 2
      src/client/js/components/Page/DisplaySwitcher.jsx
  14. 2 0
      src/client/js/components/Page/RevisionRenderer.jsx
  15. 5 1
      src/client/js/components/PageAccessories.jsx
  16. 6 4
      src/client/js/components/PageAccessoriesModal.jsx
  17. 16 6
      src/client/js/components/PageAccessoriesModalControl.jsx
  18. 2 1
      src/client/js/components/PageEditor/DrawioModal.jsx
  19. 2 6
      src/client/js/components/PageHistory.jsx
  20. 13 3
      src/client/js/components/PageHistory/PageRevisionTable.jsx
  21. 32 3
      src/client/js/components/PageHistory/RevisionDiff.jsx
  22. 29 28
      src/client/js/components/RevisionComparer/RevisionComparer.jsx
  23. 75 27
      src/client/js/components/Sidebar/CustomSidebar.jsx
  24. 1 1
      src/client/js/components/Sidebar/SidebarNav.jsx
  25. 20 4
      src/client/js/components/StaffCredit/StaffCredit.jsx
  26. 2 2
      src/client/js/components/TableOfContents.jsx
  27. 1 7
      src/client/js/services/AdminUserGroupDetailContainer.js
  28. 12 2
      src/client/js/services/NavigationContainer.js
  29. 5 0
      src/client/js/services/PageHistoryContainer.js
  30. 0 16
      src/client/js/services/RevisionComparerContainer.js
  31. 13 0
      src/client/js/util/locale-utils.js
  32. 14 0
      src/client/styles/scss/_on-edit.scss
  33. 33 4
      src/client/styles/scss/_page-history.scss
  34. 50 0
      src/client/styles/scss/_sidebar-wiki.scss
  35. 1 0
      src/client/styles/scss/style-app.scss
  36. 10 0
      src/client/styles/scss/theme/_apply-colors.scss
  37. 0 49
      src/server/models/bookmark.js
  38. 7 44
      src/server/models/page.js
  39. 0 57
      src/server/models/revision.js
  40. 1 5
      src/server/models/user-group-relation.js
  41. 2 2
      src/server/models/user-group.js
  42. 2 2
      src/server/models/user.js
  43. 4 5
      src/server/routes/apiv3/attachment.js
  44. 8 1
      src/server/routes/apiv3/bookmarks.js
  45. 2 0
      src/server/routes/apiv3/index.js
  46. 17 2
      src/server/routes/apiv3/pages.js
  47. 14 5
      src/server/routes/apiv3/revisions.js
  48. 52 0
      src/server/routes/apiv3/staffs.js
  49. 2 1
      src/server/routes/apiv3/user-group-relation.js
  50. 23 17
      src/server/routes/apiv3/user-group.js
  51. 1 1
      src/server/routes/attachment.js
  52. 23 27
      src/server/routes/comment.js
  53. 0 1
      src/server/routes/index.js
  54. 34 56
      src/server/routes/page.js
  55. 6 0
      src/server/routes/search.js
  56. 0 11
      src/server/routes/user.js
  57. 3 3
      src/server/service/page.js
  58. 1 1
      src/server/service/search-delegator/elasticsearch.js
  59. 14 4
      src/server/service/user-notification/index.js
  60. 2 2
      src/server/views/widget/page_content.html
  61. 4 23
      src/test/models/page.test.js

+ 18 - 0
.github/workflows/ci.yml

@@ -75,6 +75,10 @@ jobs:
         image: mongo:4.4
         ports:
         - 27017/tcp
+      mongodb36:
+        image: mongo:3.6
+        ports:
+        - 27017/tcp
 
     steps:
     - uses: actions/checkout@v2
@@ -114,6 +118,11 @@ jobs:
         yarn test
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
+    - name: yarn test with MongoDB 3.6
+      run: |
+        yarn test
+      env:
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb36.ports['27017'] }}/growi_test
 
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
@@ -209,6 +218,10 @@ jobs:
         image: mongo:4.4
         ports:
         - 27017/tcp
+      mongodb36:
+        image: mongo:3.6
+        ports:
+        - 27017/tcp
 
     steps:
     - uses: actions/checkout@v2
@@ -271,6 +284,11 @@ jobs:
         yarn server:prod:ci
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-${{ steps.getdbname.outputs.suffix }}
+    - name: yarn server:prod:ci with MongoDB 3.6
+      run: |
+        yarn server:prod:ci
+      env:
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb36.ports['27017'] }}/growi-${{ steps.getdbname.outputs.suffix }}
     - name: Upload report as artifact
       uses: actions/upload-artifact@v2
       with:

+ 27 - 2
CHANGES.md

@@ -1,11 +1,36 @@
 # CHANGES
 
-## v4.2.9-RC
+## v4.2.13-RC
+
+* 
+
+## v4.2.12
+
+* Feature: Custom Sidebar
+* Fix: Set language correctly for draw.io (diagrams.net)
+
+## v4.2.11
+
+* Fix: Rename decendants is not working
+    * Introduced by v4.2.8
+
+
+## v4.2.10
+
+* Feature: Staff Credits for apps on GROWI.cloud 
+* Improvement: Hackmd button behavior when disabled
+* Improvement: Layout of comparing revisions
+* Fix: Empty trash is not working
+
+## v4.2.9
 
 * Feature: Comparing revisions
 * Improvement: Memory consumption when re-indexing for full text searching
 * Improvement: Site URL settings valildation
-* Fix: Screen transition without displaying notice on browsers except Chrome
+* Fix: Show comfirmation when transiting page without save
+* Fix: Save slack channels history when user trigger notification is invoked
+* Fix: The label of alerts for move/rename/delete are borken
+
 ## v4.2.8
 
 * Improvement: Performance for pages to rename/duplicate/delete/revert pages

+ 0 - 1
Procfile

@@ -1 +0,0 @@
-web: npm run server:prod

+ 1 - 1
README.md

@@ -31,7 +31,7 @@ Table Of Contents
 - [Features](#features)
 - [Quick Start for Production](#quick-start-for-production)
     - [docker-compose](#docker-compose)
-    - [Helm (Experimental)](#helm)
+    - [Helm (Experimental)](#helm-experimental)
     - [On-premise](#on-premise)
 - [Environment Variables](#environment-variables)
 - [Documentation](#documentation)

+ 1 - 1
bin/github-actions/list-branches.js

@@ -14,7 +14,7 @@ const url = require('url');
 
 const EXCLUDE_TERM_DAYS = 14;
 const EXCLUDE_PATTERNS = [
-  /^feat\/custom-sidebar-2$/,
+  /^support\/apply-tsed$/,
   // https://regex101.com/r/Lnx7Pz/3
   /^dev\/[\d.x]*$/,
   /^release\/.+$/,

+ 0 - 3
bin/heroku/install-packages.sh

@@ -1,3 +0,0 @@
-#!/bin/sh
-
-yarn add $ADDITIONAL_PACKAGES

+ 4 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.2.9-RC",
+  "version": "4.2.13-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -24,12 +24,15 @@
     "build:apiv3:jsdoc": "cross-env API_VERSION=3 npm run build:api:jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "build:apiv1:jsdoc": "cross-env API_VERSION=1 npm run build:api:jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "build:dev:app:watch": "npm run build:dev:app -- --watch",
+    "build:dev:app:watch:poll": "npm run build:dev:app -- --watch --watch-poll",
     "build:dev:app": "env-cmd -f config/env.dev.js webpack --config config/webpack.dev.js --progress",
     "build:dev:watch": "npm run build:dev:app:watch",
+    "build:dev:watch:poll": "npm run build:dev:app:watch:poll",
     "build:dev": "npm run build:dev:app",
     "build:prod:analyze": "cross-env ANALYZE=1 npm run build:prod",
     "build:prod": "env-cmd -f config/env.prod.js webpack --config config/webpack.prod.js --profile --bail",
     "build": "npm run build:dev:watch",
+    "build:poll": "npm run build:dev:watch:poll",
     "clean:app": "rimraf -- public/js public/styles",
     "clean:report": "rimraf -- report",
     "clean": "npm-run-all -p clean:*",

+ 4 - 0
src/client/js/components/StaffCredit/Contributor.js → resource/Contributor.js

@@ -1,5 +1,6 @@
 const contributors = [
   {
+    order: 1,
     sectionName: 'GROWI VILLAGE',
     additionalClass: '',
     memberGroups: [
@@ -47,6 +48,7 @@ const contributors = [
     ],
   },
   {
+    order: 10,
     sectionName: 'CONTRIBUTER',
     additionalClass: '',
     memberGroups: [
@@ -92,6 +94,7 @@ const contributors = [
     ],
   },
   {
+    order: 100,
     sectionName: 'VULNERABILITY HUNTER',
     additionalClass: '',
     memberGroups: [
@@ -111,6 +114,7 @@ const contributors = [
     ],
   },
   {
+    order: 200,
     sectionName: 'SPECIAL THANKS',
     additionalClass: '',
     memberGroups: [

+ 3 - 3
resource/locales/en_US/translation.json

@@ -165,6 +165,7 @@
   },
   "not_found_page": {
     "Create Page": "Create Page",
+    "page_not_exist": "This page does not exist.",
     "page_not_exist_alert": "This page does not exist. Please create a new page."
   },
   "custom_navigation": {
@@ -330,10 +331,9 @@
     "revision": "version",
     "comparing_source": "Source",
     "comparing_target": "Target",
-    "comparing_revisions": "Comparing versions",
+    "comparing_revisions": "Comparing the difference",
     "compare_latest":"Compare latest revision",
-    "compare_previous":"Compare previous revision",
-    "comparing_with_latest": "Always compare with the latest version"
+    "compare_previous":"Compare previous revision"
   },
   "modal_rename": {
     "label": {

+ 3 - 3
resource/locales/ja_JP/translation.json

@@ -168,6 +168,7 @@
   },
   "not_found_page": {
     "Create Page": "ページを作成する",
+    "page_not_exist": "このページは存在しません。",
     "page_not_exist_alert": "このページは存在しません。新たに作成する必要があります。"
   },
   "custom_navigation": {
@@ -332,10 +333,9 @@
     "revision": "バージョン",
     "comparing_source": "ソース",
     "comparing_target": "ターゲット",
-    "comparing_revisions": "比較",
+    "comparing_revisions": "差分を比較する",
     "compare_latest":"最新と比較",
-    "compare_previous":"1つ前のバージョンと比較",
-    "comparing_with_latest": "常に最新バージョンと比較する"
+    "compare_previous":"1つ前のバージョンと比較"
   },
   "modal_rename": {
     "label": {

+ 3 - 3
resource/locales/zh_CN/translation.json

@@ -166,6 +166,7 @@
   },
   "not_found_page": {
     "Create Page": "创建页面",
+    "page_not_exist": "该页面不存在",
     "page_not_exist_alert": "该页面不存在,请创建一个新页面"
   },
   "custom_navigation": {
@@ -311,10 +312,9 @@
     "revision": "版本",
     "comparing_source": "源头",
     "comparing_target": "目标",
-    "comparing_revisions": "比较版本",
+    "comparing_revisions": "比较两者的区别",
     "compare_latest":"比較最新版本",
-    "compare_previous":"比較以前的版本",
-    "comparing_with_latest": "一定要与最新版本进行比较"
+    "compare_previous":"比較以前的版本"
   },
 	"modal_rename": {
 		"label": {

+ 26 - 4
src/client/js/components/Navbar/PageEditorModeManager.jsx

@@ -3,9 +3,12 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
+import { withUnstatedContainers } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+
 /* eslint-disable react/prop-types */
 const PageEditorModeButtonWrapper = React.memo(({
-  editorMode, isBtnDisabled, onClick, targetMode, icon, label,
+  editorMode, isBtnDisabled, onClick, targetMode, icon, label, id,
 }) => {
   const classNames = [`btn btn-outline-primary ${targetMode}-button px-1`];
   if (editorMode === targetMode) {
@@ -20,6 +23,7 @@ const PageEditorModeButtonWrapper = React.memo(({
       type="button"
       className={classNames.join(' ')}
       onClick={() => { onClick(targetMode) }}
+      id={id}
     >
       <span className="d-flex flex-column flex-md-row justify-content-center">
         <span className="grw-page-editor-mode-manager-icon mr-md-1">{icon}</span>
@@ -32,9 +36,14 @@ const PageEditorModeButtonWrapper = React.memo(({
 
 function PageEditorModeManager(props) {
   const {
-    t, editorMode, onPageEditorModeButtonClicked, isBtnDisabled, isDeviceSmallerThanMd,
+    t, appContainer,
+    editorMode, onPageEditorModeButtonClicked, isBtnDisabled, isDeviceSmallerThanMd,
   } = props;
 
+  const isAdmin = appContainer.isAdmin;
+  const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
+  const showHackmdBtn = isHackmdEnabled || isAdmin;
+  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== 'hackmd';
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
     if (isBtnDisabled) {
@@ -73,7 +82,7 @@ function PageEditorModeManager(props) {
             label={t('Edit')}
           />
         )}
-        {(!isDeviceSmallerThanMd || editorMode === 'view') && (
+        {(!isDeviceSmallerThanMd || editorMode === 'view') && showHackmdBtn && (
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
@@ -81,6 +90,7 @@ function PageEditorModeManager(props) {
             targetMode="hackmd"
             icon={<i className="fa fa-file-text-o" />}
             label={t('hackmd.hack_md')}
+            id="grw-page-editor-mode-manager-hackmd-button"
           />
         )}
       </div>
@@ -89,6 +99,11 @@ function PageEditorModeManager(props) {
           {t('Not available for guest')}
         </UncontrolledTooltip>
       )}
+      {!isBtnDisabled && showHackmdDisabledTooltip && (
+        <UncontrolledTooltip placement="top" target="grw-page-editor-mode-manager-hackmd-button" fade={false}>
+          {t('hackmd.not_set_up')}
+        </UncontrolledTooltip>
+      )}
     </>
   );
 
@@ -96,6 +111,8 @@ function PageEditorModeManager(props) {
 
 PageEditorModeManager.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   onPageEditorModeButtonClicked: PropTypes.func,
   isBtnDisabled: PropTypes.bool,
   editorMode: PropTypes.string,
@@ -107,4 +124,9 @@ PageEditorModeManager.defaultProps = {
   isDeviceSmallerThanMd: false,
 };
 
-export default withTranslation()(PageEditorModeManager);
+/**
+ * Wrapper component for using unstated
+ */
+const PageEditorModeManagerWrapper = withUnstatedContainers(PageEditorModeManager, [AppContainer]);
+
+export default withTranslation()(PageEditorModeManagerWrapper);

+ 2 - 2
src/client/js/components/Page/DisplaySwitcher.jsx

@@ -19,7 +19,7 @@ const DisplaySwitcher = (props) => {
     navigationContainer, pageContainer,
   } = props;
   const { editorMode } = navigationContainer.state;
-  const { pageUser } = pageContainer.state;
+  const { isPageExist, pageUser } = pageContainer.state;
 
   return (
     <>
@@ -30,7 +30,7 @@ const DisplaySwitcher = (props) => {
             <div className="grw-side-contents-container">
               <div className="grw-side-contents-sticky-container">
                 <div className="border-bottom pb-1">
-                  <PageAccessories />
+                  <PageAccessories isNotFoundPage={!isPageExist} />
                 </div>
 
                 <div className="d-none d-lg-block">

+ 2 - 0
src/client/js/components/Page/RevisionRenderer.jsx

@@ -110,6 +110,7 @@ class RevisionRenderer extends React.PureComponent {
       <RevisionBody
         html={this.state.html}
         isMathJaxEnabled={isMathJaxEnabled}
+        additionalClassName={this.props.additionalClassName}
         renderMathJaxOnInit
       />
     );
@@ -129,6 +130,7 @@ RevisionRenderer.propTypes = {
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.string,
+  additionalClassName: PropTypes.string,
 };
 
 export default RevisionRendererWrapper;

+ 5 - 1
src/client/js/components/PageAccessories.jsx

@@ -9,7 +9,7 @@ import AppContainer from '../services/AppContainer';
 import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
 
 const PageAccessories = (props) => {
-  const { appContainer, pageAccessoriesContainer } = props;
+  const { appContainer, pageAccessoriesContainer, isNotFoundPage } = props;
   const { isGuestUser, isSharedUser } = appContainer;
 
   return (
@@ -17,10 +17,12 @@ const PageAccessories = (props) => {
       <PageAccessoriesModalControl
         isGuestUser={isGuestUser}
         isSharedUser={isSharedUser}
+        isNotFoundPage={isNotFoundPage}
       />
       <PageAccessoriesModal
         isGuestUser={isGuestUser}
         isSharedUser={isSharedUser}
+        isNotFoundPage={isNotFoundPage}
         isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
         onClose={pageAccessoriesContainer.closePageAccessoriesModal}
       />
@@ -35,6 +37,8 @@ const PageAccessoriesWrapper = withUnstatedContainers(PageAccessories, [AppConta
 PageAccessories.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+
+  isNotFoundPage: PropTypes.bool.isRequired,
 };
 
 export default PageAccessoriesWrapper;

+ 6 - 4
src/client/js/components/PageAccessoriesModal.jsx

@@ -24,7 +24,7 @@ import ExpandOrContractButton from './ExpandOrContractButton';
 
 const PageAccessoriesModal = (props) => {
   const {
-    t, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser,
+    t, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser, isNotFoundPage,
   } = props;
   const { switchActiveTab } = pageAccessoriesContainer;
   const { activeTab, activeComponents } = pageAccessoriesContainer.state;
@@ -48,21 +48,22 @@ const PageAccessoriesModal = (props) => {
         Icon: HistoryIcon,
         i18n: t('History'),
         index: 2,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage,
       },
       attachment: {
         Icon: AttachmentIcon,
         i18n: t('attachment_data'),
         index: 3,
+        isLinkEnabled: v => !isNotFoundPage,
       },
       shareLink: {
         Icon: ShareLinkIcon,
         i18n: t('share_links.share_link_management'),
         index: 4,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage,
       },
     };
-  }, [t, isGuestUser, isSharedUser]);
+  }, [t, isGuestUser, isSharedUser, isNotFoundPage]);
 
   const closeModalHandler = useCallback(() => {
     if (onClose == null) {
@@ -149,6 +150,7 @@ PageAccessoriesModal.propTypes = {
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
   isGuestUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,
+  isNotFoundPage: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func,
 };

+ 16 - 6
src/client/js/components/PageAccessoriesModalControl.jsx

@@ -17,7 +17,7 @@ import { withUnstatedContainers } from './UnstatedUtils';
 
 const PageAccessoriesModalControl = (props) => {
   const {
-    t, pageAccessoriesContainer, isGuestUser, isSharedUser,
+    t, pageAccessoriesContainer, isGuestUser, isSharedUser, isNotFoundPage,
   } = props;
 
   const accessoriesBtnList = useMemo(() => {
@@ -37,27 +37,36 @@ const PageAccessoriesModalControl = (props) => {
       {
         name: 'pageHistory',
         Icon: <HistoryIcon />,
-        disabled: isGuestUser || isSharedUser,
+        disabled: isGuestUser || isSharedUser || isNotFoundPage,
         i18n: t('History'),
       },
       {
         name: 'attachment',
         Icon: <AttachmentIcon />,
-        disabled: false,
+        disabled: isNotFoundPage,
         i18n: t('attachment_data'),
       },
       {
         name: 'shareLink',
         Icon: <ShareLinkIcon />,
-        disabled: isGuestUser || isSharedUser,
+        disabled: isGuestUser || isSharedUser || isNotFoundPage,
         i18n: t('share_links.share_link_management'),
       },
     ];
-  }, [t, isGuestUser, isSharedUser]);
+  }, [t, isGuestUser, isSharedUser, isNotFoundPage]);
 
   return (
     <div className="grw-page-accessories-control d-flex flex-nowrap align-items-center justify-content-end justify-content-lg-between">
       {accessoriesBtnList.map((accessory) => {
+
+        let tooltipMessage;
+        if (accessory.disabled) {
+          tooltipMessage = isNotFoundPage ? t('not_found_page.page_not_exist') : t('Not available for guest');
+        }
+        else {
+          tooltipMessage = accessory.i18n;
+        }
+
         return (
           <Fragment key={accessory.name}>
             <div id={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`}>
@@ -70,7 +79,7 @@ const PageAccessoriesModalControl = (props) => {
               </button>
             </div>
             <UncontrolledTooltip placement="top" target={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`} fade={false}>
-              {accessory.disabled ? t('Not available for guest') : accessory.i18n}
+              {tooltipMessage}
             </UncontrolledTooltip>
           </Fragment>
         );
@@ -94,6 +103,7 @@ PageAccessoriesModalControl.propTypes = {
 
   isGuestUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,
+  isNotFoundPage: PropTypes.bool.isRequired,
 };
 
 export default withTranslation()(PageAccessoriesModalControlWrapper);

+ 2 - 1
src/client/js/components/PageEditor/DrawioModal.jsx

@@ -10,6 +10,7 @@ import {
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
 import EditorContainer from '../../services/EditorContainer';
+import { getDiagramsNetLangCode } from '../../util/locale-utils';
 
 class DrawioModal extends React.PureComponent {
 
@@ -125,7 +126,7 @@ class DrawioModal extends React.PureComponent {
     // refs: https://desk.draw.io/support/solutions/articles/16000042546-what-url-parameters-are-supported-
     url.searchParams.append('spin', 1);
     url.searchParams.append('embed', 1);
-    url.searchParams.append('lang', i18next.language);
+    url.searchParams.append('lang', getDiagramsNetLangCode(i18next.language));
     url.searchParams.append('ui', 'atlas');
     url.searchParams.append('configure', 1);
 

+ 2 - 6
src/client/js/components/PageHistory.jsx

@@ -2,7 +2,6 @@ import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
-import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '../util/apiNotification';
 
@@ -17,7 +16,7 @@ import RevisionComparerContainer from '../services/RevisionComparerContainer';
 const logger = loggerFactory('growi:PageHistory');
 
 function PageHistory(props) {
-  const { pageHistoryContainer, revisionComparerContainer, t } = props;
+  const { pageHistoryContainer, revisionComparerContainer } = props;
   const { getPreviousRevision } = pageHistoryContainer;
   const {
     activePage, totalPages, pagingLimit, revisions, diffOpened,
@@ -70,7 +69,6 @@ function PageHistory(props) {
 
   return (
     <div className="revision-history">
-      <h3 className="pb-3">{t('page_history.revision_list')}</h3>
       <PageRevisionTable
         pageHistoryContainer={pageHistoryContainer}
         revisionComparerContainer={revisionComparerContainer}
@@ -90,10 +88,8 @@ function PageHistory(props) {
 const RenderPageHistoryWrapper = withUnstatedContainers(withLoadingSppiner(PageHistory), [PageHistroyContainer, RevisionComparerContainer]);
 
 PageHistory.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
   pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
   revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
 };
 
-export default withTranslation()(RenderPageHistoryWrapper);
+export default RenderPageHistoryWrapper;

+ 13 - 3
src/client/js/components/PageHistory/PageRevisionTable.jsx

@@ -18,7 +18,7 @@ class PageRevisionTable extends React.Component {
    */
   renderRow(revision, previousRevision, hasDiff, isContiguousNodiff) {
     const { revisionComparerContainer, t } = this.props;
-    const { latestRevision } = this.props.pageHistoryContainer.state;
+    const { latestRevision, oldestRevision } = this.props.pageHistoryContainer.state;
     const revisionId = revision._id;
     const revisionDiffOpened = this.props.diffOpened[revisionId] || false;
     const { sourceRevision, targetRevision } = revisionComparerContainer.state;
@@ -48,10 +48,19 @@ class PageRevisionTable extends React.Component {
             {hasDiff && (
               <div className="ml-md-3 mt-auto">
                 <div className="btn-group">
-                  <button type="button" className="btn btn-outline-secondary btn-sm" onClick={handleCompareLatestRevisionButton}>
+                  <button
+                    type="button"
+                    className="btn btn-outline-secondary btn-sm"
+                    onClick={handleCompareLatestRevisionButton}
+                  >
                     {t('page_history.compare_latest')}
                   </button>
-                  <button type="button" className="btn btn-outline-secondary btn-sm" onClick={handleComparePreviousRevisionButton}>
+                  <button
+                    type="button"
+                    className="btn btn-outline-secondary btn-sm"
+                    onClick={handleComparePreviousRevisionButton}
+                    disabled={revision === oldestRevision}
+                  >
                     {t('page_history.compare_previous')}
                   </button>
                 </div>
@@ -117,6 +126,7 @@ class PageRevisionTable extends React.Component {
         previousRevision = revision; // if it is the first revision, show full text as diff text
       }
 
+
       const hasDiff = revision.hasDiffToPrev !== false; // set 'true' if undefined for backward compatibility
       const isContiguousNodiff = !hasDiff && !hasDiffPrev;
 

+ 32 - 3
src/client/js/components/PageHistory/RevisionDiff.jsx

@@ -1,12 +1,16 @@
+/* eslint-disable react/no-danger */
 import React from 'react';
 import PropTypes from 'prop-types';
 
 import { createPatch } from 'diff';
 import { html } from 'diff2html';
+import { withTranslation } from 'react-i18next';
+import UserDate from '../User/UserDate';
 
-export default class RevisionDiff extends React.Component {
+class RevisionDiff extends React.Component {
 
   render() {
+    const { t } = this.props;
     const currentRevision = this.props.currentRevision;
     const previousRevision = this.props.previousRevision;
     const revisionDiffOpened = this.props.revisionDiffOpened;
@@ -38,14 +42,39 @@ export default class RevisionDiff extends React.Component {
     }
 
     const diffView = { __html: diffViewHTML };
-    // eslint-disable-next-line react/no-danger
-    return <div className="revision-history-diff" dangerouslySetInnerHTML={diffView} />;
+    return (
+      <>
+        <div className="comparison-header">
+          <div className="container pt-1 pr-0">
+            <div className="row">
+              <div className="col comparison-source-wrapper pt-1 px-0">
+                <span className="comparison-source pr-3">{t('page_history.comparing_source')}</span><UserDate dateTime={previousRevision.createdAt} />
+                <a href={`?revision=${previousRevision._id}`} className="ml-3">
+                  <i className="icon-login"></i>
+                </a>
+
+              </div>
+              <div className="col comparison-target-wrapper pt-1">
+                <span className="comparison-target pr-3">{t('page_history.comparing_target')}</span><UserDate dateTime={currentRevision.createdAt} />
+                <a href={`?revision=${currentRevision._id}`} className="ml-3">
+                  <i className="icon-login"></i>
+                </a>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div className="revision-history-diff pb-1" dangerouslySetInnerHTML={diffView} />
+      </>
+    );
   }
 
 }
 
 RevisionDiff.propTypes = {
+  t: PropTypes.func.isRequired,
   currentRevision: PropTypes.object.isRequired,
   previousRevision: PropTypes.object.isRequired,
   revisionDiffOpened: PropTypes.bool.isRequired,
 };
+
+export default withTranslation()(RevisionDiff);

+ 29 - 28
src/client/js/components/RevisionComparer/RevisionComparer.jsx

@@ -45,30 +45,28 @@ const RevisionComparer = (props) => {
     const { path } = revisionComparerContainer.pageContainer.state;
     const { sourceRevision, targetRevision } = revisionComparerContainer.state;
 
-    const urlParams = (sourceRevision && targetRevision ? `?compare=${sourceRevision._id}...${targetRevision._id}` : '');
-    return encodeSpaces(decodeURI(`${origin}/${path}${urlParams}`));
+    const url = new URL(path, origin);
+
+    if (sourceRevision != null && targetRevision != null) {
+      const urlParams = `${sourceRevision._id}...${targetRevision._id}`;
+      url.searchParams.set('compare', urlParams);
+    }
+
+    return encodeSpaces(decodeURI(url));
   };
 
   const { sourceRevision, targetRevision } = revisionComparerContainer.state;
-  const showDiff = (sourceRevision && targetRevision);
+
+  if (sourceRevision == null || targetRevision == null) {
+    return null;
+  }
+
+  const isNodiff = sourceRevision._id === targetRevision._id;
 
   return (
     <div className="revision-compare">
       <div className="d-flex">
-        <h3 className="align-self-center mb-0">{ t('page_history.comparing_revisions') }</h3>
-        <div className="align-self-center ml-3">
-          <div className="custom-control custom-switch">
-            <input
-              type="checkbox"
-              className="custom-control-input"
-              id="comparingWithLatest"
-              onChange={() => revisionComparerContainer.toggleCompareWithLatest()}
-            />
-            <label className="custom-control-label" htmlFor="comparingWithLatest">
-              { t('page_history.comparing_with_latest') }
-            </label>
-          </div>
-        </div>
+        <h4 className="align-self-center">{ t('page_history.comparing_revisions') }</h4>
         <Dropdown
           className="grw-copy-dropdown align-self-center ml-auto"
           isOpen={dropdownOpen}
@@ -80,7 +78,7 @@ const RevisionComparer = (props) => {
           >
             <i className="ti-clipboard"></i>
           </DropdownToggle>
-          <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: null } }}>
+          <DropdownMenu positionFixed right modifiers={{ preventOverflow: { boundariesElement: null } }}>
             {/* Page path URL */}
             <CopyToClipboard text={pagePathUrl()}>
               <DropdownItem className="px-3">
@@ -92,16 +90,19 @@ const RevisionComparer = (props) => {
         </Dropdown>
       </div>
 
-      <hr />
-
-      <div className="revision-compare-outer">
-        { showDiff && (
-          <RevisionDiff
-            revisionDiffOpened
-            previousRevision={sourceRevision}
-            currentRevision={targetRevision}
-          />
-        )}
+      <div className={`revision-compare-container ${isNodiff ? 'nodiff' : ''}`}>
+        { isNodiff
+          ? (
+            <span className="h3 text-muted">{t('No diff')}</span>
+          )
+          : (
+            <RevisionDiff
+              revisionDiffOpened
+              previousRevision={sourceRevision}
+              currentRevision={targetRevision}
+            />
+          )
+        }
       </div>
     </div>
   );

+ 75 - 27
src/client/js/components/Sidebar/CustomSidebar.jsx

@@ -1,45 +1,93 @@
-import React from 'react';
-// import PropTypes from 'prop-types';
+import React, {
+  useState, useCallback, useEffect,
+} from 'react';
+import PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
+import RevisionRenderer from '../Page/RevisionRenderer';
 
-class CustomSidebar extends React.Component {
+const logger = loggerFactory('growi:cli:CustomSidebar');
 
-  static propTypes = {
-  };
 
-  state = {
-  };
+const SidebarNotFound = () => {
+  return (
+    <div className="grw-sidebar-content-header h5 text-center p-3">
+      <a href="/Sidebar#edit">
+        <i className="icon-magic-wand"></i> Create <strong>/Sidebar</strong> page
+      </a>
+    </div>
+  );
+};
 
-  renderHeaderWordmark() {
-    return <h3>Custom Sidebar</h3>;
-  }
+const CustomSidebar = (props) => {
 
-  render() {
-    return (
-      <>
-        <div className="grw-sidebar-content-header p-3 d-flex">
-          <h3 className="mb-0">Custom Sidebar</h3>
-          <button type="button" className="btn btn-sm btn-outline-secondary ml-auto" onClick={this.reloadData}>
-            <i className="icon icon-reload"></i>
-          </button>
-        </div>
-        <div className="grw-sidebar-content-header p-3">
-          (TBD) Under implementation
+  const { appContainer } = props;
+  const { apiGet } = appContainer;
+
+  const [isMounted, setMounted] = useState(false);
+  const [markdown, setMarkdown] = useState();
+
+  const growiRenderer = appContainer.getRenderer('sidebar');
+
+  // TODO: refactor with SWR
+  const fetchDataAndRenderHtml = useCallback(async() => {
+    let page = null;
+    try {
+      const result = await apiGet('/pages.get', { path: '/Sidebar' });
+      page = result.page;
+    }
+    catch (e) {
+      logger.warn(e.message);
+      return;
+    }
+    finally {
+      setMounted(true);
+    }
+
+    setMarkdown(page.revision.body);
+  }, [apiGet]);
+
+  useEffect(() => {
+    fetchDataAndRenderHtml();
+  }, [fetchDataAndRenderHtml]);
+
+  return (
+    <>
+      <div className="grw-sidebar-content-header p-3 d-flex">
+        <h3 className="mb-0">
+          Custom Sidebar
+          <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
+        </h3>
+        <button type="button" className="btn btn-sm btn-outline-secondary ml-auto" onClick={fetchDataAndRenderHtml}>
+          <i className="icon icon-reload"></i>
+        </button>
+      </div>
+      { isMounted && markdown == null && <SidebarNotFound /> }
+      {/* eslint-disable-next-line react/no-danger */}
+      { markdown != null && (
+        <div className="p-3">
+          <RevisionRenderer
+            growiRenderer={growiRenderer}
+            markdown={markdown}
+            additionalClassName="grw-custom-sidebar-content"
+          />
         </div>
-      </>
-    );
+      ) }
+    </>
+  );
 
-  }
+};
 
-}
+CustomSidebar.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
 
 /**
  * Wrapper component for using unstated
  */
 const CustomSidebarWrapper = withUnstatedContainers(CustomSidebar, [AppContainer]);
 
-export default withTranslation()(CustomSidebarWrapper);
+export default CustomSidebarWrapper;

+ 1 - 1
src/client/js/components/Sidebar/SidebarNav.jsx

@@ -23,7 +23,7 @@ class SidebarNav extends React.Component {
       onItemSelected(contentsId);
     }
 
-    navigationContainer.setState({ sidebarContentsId: contentsId });
+    navigationContainer.selectSidebarContents(contentsId);
   }
 
   PrimaryItem = ({ id, label, iconName }) => {

+ 20 - 4
src/client/js/components/StaffCredit/StaffCredit.jsx

@@ -4,7 +4,8 @@ import loggerFactory from '@alias/logger';
 import {
   Modal, ModalBody,
 } from 'reactstrap';
-import contributors from './Contributor';
+import AppContainer from '../../services/AppContainer';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 /**
  * Page staff credit component
@@ -17,13 +18,14 @@ import contributors from './Contributor';
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:cli:StaffCredit');
 
-export default class StaffCredit extends React.Component {
+class StaffCredit extends React.Component {
 
   constructor(props) {
 
     super(props);
     this.state = {
       isShown: true,
+      contributors: null,
     };
     this.deleteCredit = this.deleteCredit.bind(this);
   }
@@ -57,7 +59,7 @@ export default class StaffCredit extends React.Component {
 
   renderContributors() {
     if (this.state.isShown) {
-      const credit = contributors.map((contributor) => {
+      const credit = this.state.contributors.map((contributor) => {
         // construct members elements
         const memberGroups = contributor.memberGroups.map((memberGroup, idx) => {
           return this.renderMembers(memberGroup, `${contributor.sectionName}-group${idx}`);
@@ -83,7 +85,11 @@ export default class StaffCredit extends React.Component {
     return null;
   }
 
-  componentDidMount() {
+  async componentDidMount() {
+    const res = await this.props.appContainer.apiv3Get('/staffs');
+    const contributors = res.data.contributors;
+    this.setState({ contributors });
+
     setTimeout(() => {
       // px / sec
       const scrollSpeed = 200;
@@ -103,6 +109,10 @@ export default class StaffCredit extends React.Component {
   render() {
     const { onClosed } = this.props;
 
+    if (this.state.contributors === null) {
+      return <></>;
+    }
+
     return (
       <Modal
         isOpen={this.state.isShown}
@@ -123,6 +133,12 @@ export default class StaffCredit extends React.Component {
   }
 
 }
+
+const StaffCreditWrapper = withUnstatedContainers(StaffCredit, [AppContainer]);
+
 StaffCredit.propTypes = {
   onClosed: PropTypes.func,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
+
+export default StaffCreditWrapper;

+ 2 - 2
src/client/js/components/TableOfContents.jsx

@@ -33,8 +33,8 @@ const TableOfContents = (props) => {
     const containerComputedStyle = getComputedStyle(containerElem);
     const containerPaddingTop = parseFloat(containerComputedStyle['padding-top']);
 
-    // get smaller bottom line of window height - .system-version height) and containerTop
-    let bottom = Math.min(window.innerHeight - 20, parentBottom);
+    // get smaller bottom line of window height - the height of ContentLinkButtons and .system-version height) and containerTop
+    let bottom = Math.min(window.innerHeight - 41 - 20, parentBottom);
 
     if (isUserPage) {
       // raise the bottom line by the height and margin-top of UserContentLinks

+ 1 - 7
src/client/js/services/AdminUserGroupDetailContainer.js

@@ -161,13 +161,7 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
     // do not add users for ducaplicate
     if (res.data.userGroupRelation == null) { return }
 
-    const { userGroupRelation } = res.data;
-
-    this.setState((prevState) => {
-      return {
-        userGroupRelations: [...prevState.userGroupRelations, userGroupRelation],
-      };
-    });
+    this.init();
   }
 
   /**

+ 12 - 2
src/client/js/services/NavigationContainer.js

@@ -31,7 +31,7 @@ export default class NavigationContainer extends Container {
       isDrawerMode: null,
       isDrawerOpened: false,
 
-      sidebarContentsId: 'recent',
+      sidebarContentsId: localStorage.sidebarContentsId || 'recent',
 
       isScrollTop: true,
 
@@ -109,6 +109,7 @@ export default class NavigationContainer extends Container {
       $('body').removeClass('on-edit');
       $('body').removeClass('builtin-editor');
       $('body').removeClass('hackmd');
+      $('body').removeClass('pathname-sidebar');
       window.history.replaceState(null, '', window.location.pathname);
     }
 
@@ -116,6 +117,10 @@ export default class NavigationContainer extends Container {
       $('body').addClass('on-edit');
       $('body').addClass('builtin-editor');
       $('body').removeClass('hackmd');
+      // editing /Sidebar
+      if (window.location.pathname === '/Sidebar') {
+        $('body').addClass('pathname-sidebar');
+      }
       window.location.hash = '#edit';
     }
 
@@ -123,8 +128,8 @@ export default class NavigationContainer extends Container {
       $('body').addClass('on-edit');
       $('body').addClass('hackmd');
       $('body').removeClass('builtin-editor');
+      $('body').removeClass('pathname-sidebar');
       window.location.hash = '#hackmd';
-
     }
 
     this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
@@ -185,6 +190,11 @@ export default class NavigationContainer extends Container {
     this.setState({ isDrawerMode, isDrawerOpened });
   }
 
+  selectSidebarContents(contentsId) {
+    window.localStorage.setItem('sidebarContentsId', contentsId);
+    this.setState({ sidebarContentsId: contentsId });
+  }
+
   openPageCreateModal() {
     if (this.appContainer.currentUser == null) {
       logger.warn('Please login or signup to create a new page.');

+ 5 - 0
src/client/js/services/PageHistoryContainer.js

@@ -25,6 +25,7 @@ export default class PageHistoryContainer extends Container {
       // set dummy rivisions for using suspense
       revisions: this.dummyRevisions,
       latestRevision: this.dummyRevisions,
+      oldestRevision: this.dummyRevisions,
       diffOpened: {},
 
       totalPages: 0,
@@ -100,6 +101,10 @@ export default class PageHistoryContainer extends Container {
       this.setState({ latestRevision: rev[0] });
     }
 
+    if (selectedPage === res.data.totalPages) {
+      this.setState({ oldestRevision: rev[lastId] });
+    }
+
     // load 0, and last default
     if (rev[0]) {
       this.fetchPageRevisionBody(rev[0]);

+ 0 - 16
src/client/js/services/RevisionComparerContainer.js

@@ -27,7 +27,6 @@ export default class RevisionComparerContainer extends Container {
     };
 
     this.initRevisions = this.initRevisions.bind(this);
-    this.toggleCompareWithLatest = this.toggleCompareWithLatest.bind(this);
   }
 
   /**
@@ -110,19 +109,4 @@ export default class RevisionComparerContainer extends Container {
     return null;
   }
 
-  /**
-   * toggle state "compareWithLatest", and if true, set "targetRevision" to the latest revision
-   */
-  toggleCompareWithLatest() {
-    const { compareWithLatest } = this.state;
-    const newCompareWithLatest = !compareWithLatest;
-
-    this.setState(
-      Object.assign(
-        { compareWithLatest: newCompareWithLatest },
-        (newCompareWithLatest === true ? { targetRevision: this.state.latestRevision } : {}),
-      ),
-    );
-  }
-
 }

+ 13 - 0
src/client/js/util/locale-utils.js

@@ -0,0 +1,13 @@
+// https://docs.google.com/spreadsheets/d/1FoYdyEraEQuWofzbYCDPKN7EdKgS_2ZrsDrOA8scgwQ
+const DIAGRAMS_NET_LANG_MAP = {
+  ja_JP: 'ja',
+  zh_CN: 'zh',
+};
+
+const getDiagramsNetLangCode = (lang) => {
+  return DIAGRAMS_NET_LANG_MAP[lang];
+};
+
+module.exports = {
+  getDiagramsNetLangCode,
+};

+ 14 - 0
src/client/styles/scss/_on-edit.scss

@@ -256,6 +256,20 @@ body.on-edit {
 
   // .builtin-editor .tab-pane#edit
 
+  // editing /Sidebar
+  &.pathname-sidebar {
+    .page-editor-preview-body {
+      width: 320px;
+      padding-top: 0;
+      margin-right: auto;
+      margin-left: auto;
+
+      .wiki {
+        @extend %grw-custom-sidebar-content;
+      }
+    }
+  }
+
   &.hackmd {
     .hackmd-preinit,
     #iframe-hackmd-container > iframe {

+ 33 - 4
src/client/styles/scss/_page-history.scss

@@ -1,3 +1,6 @@
+// @import '../scss/variables';
+// @import '../scss/override-bootstrap-variables';
+
 .revision-history-table {
   tbody {
     max-height: 250px;
@@ -19,15 +22,41 @@
 }
 
 .revision-history-diff {
-  padding-left: 40px;
   color: $gray-900;
   table-layout: fixed;
 }
 
+.comparison-header {
+  height: 34px;
+  background-color: #ffffff;
+  border: 1px solid $gray-300;
+  .comparison-source-wrapper {
+    height: 26px;
+    margin-right: 1px;
+    border-right: 1px solid $gray-300;
+    .comparison-source {
+      color: $gray-500;
+    }
+  }
+  .comparison-target-wrapper {
+    height: 26px;
+    .comparison-target {
+      color: $gray-500;
+    }
+  }
+}
+
 .revision-compare {
-  .revision-compare-outer {
+  .revision-compare-container {
     min-height: 100px;
-    max-height: 250px;
-    overflow: auto;
+
+    &.nodiff {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+  }
+  .d2h-file-header {
+    display: none;
   }
 }

+ 50 - 0
src/client/styles/scss/_sidebar-wiki.scss

@@ -0,0 +1,50 @@
+%grw-custom-sidebar-content {
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6 {
+    margin-top: 1em;
+    margin-bottom: 0.4em;
+
+    &:first-child {
+      margin-top: 0;
+    }
+  }
+
+  h1 {
+    padding-top: 0.2em;
+    font-size: 1.4em;
+    line-height: 1em;
+  }
+  h2 {
+    padding-top: 0.2em;
+    font-size: 1.2em;
+    line-height: 1em;
+  }
+  h3 {
+    font-size: 1.1em;
+  }
+  h4 {
+    font-size: 1.05em;
+  }
+  h5 {
+    font-size: 1.03em;
+  }
+
+  ul,
+  ol {
+    padding-left: 20px;
+    margin: 10px 0;
+  }
+
+  .page-list .page-list-ul {
+    padding-left: 0;
+    margin: 0;
+  }
+}
+
+.grw-custom-sidebar-content.wiki {
+  @extend %grw-custom-sidebar-content;
+}

+ 1 - 0
src/client/styles/scss/style-app.scss

@@ -59,6 +59,7 @@
 @import 'search';
 @import 'shortcuts';
 @import 'sidebar';
+@import 'sidebar-wiki';
 @import 'subnav';
 @import 'tag';
 @import 'toc';

+ 10 - 0
src/client/styles/scss/theme/_apply-colors.scss

@@ -439,6 +439,16 @@ body.on-edit {
   }
 }
 
+/*
+ * Preview for editing /Sidebar
+ */
+body.pathname-sidebar {
+  .page-editor-preview-body {
+    color: $color-sidebar-context;
+    background-color: $bgcolor-sidebar-context;
+  }
+}
+
 /*
  * GROWI Grid Edit Modal
  */

+ 0 - 49
src/server/models/bookmark.js

@@ -43,18 +43,6 @@ module.exports = function(crowi) {
     return idToCountMap;
   };
 
-  bookmarkSchema.statics.populatePage = async function(bookmarks) {
-    const Bookmark = this;
-    const User = crowi.model('User');
-
-    return Bookmark.populate(bookmarks, {
-      path: 'page',
-      populate: {
-        path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS,
-      },
-    });
-  };
-
   // bookmark チェック用
   bookmarkSchema.statics.findByPageIdAndUserId = function(pageId, userId) {
     const Bookmark = this;
@@ -70,43 +58,6 @@ module.exports = function(crowi) {
     }));
   };
 
-  /**
-   * option = {
-   *  limit: Int
-   *  offset: Int
-   *  requestUser: User
-   * }
-   */
-  bookmarkSchema.statics.findByUser = function(user, option) {
-    const Bookmark = this;
-    const requestUser = option.requestUser || null;
-
-    debug('Finding bookmark with requesting user:', requestUser);
-
-    const limit = option.limit || 50;
-    const offset = option.offset || 0;
-    const populatePage = option.populatePage || false;
-
-    return new Promise(((resolve, reject) => {
-      Bookmark
-        .find({ user: user._id })
-        .sort({ createdAt: -1 })
-        .skip(offset)
-        .limit(limit)
-        .exec((err, bookmarks) => {
-          if (err) {
-            return reject(err);
-          }
-
-          if (!populatePage) {
-            return resolve(bookmarks);
-          }
-
-          return Bookmark.populatePage(bookmarks, requestUser).then(resolve);
-        });
-    }));
-  };
-
   bookmarkSchema.statics.add = async function(page, user) {
     const Bookmark = this;
 

+ 7 - 44
src/server/models/page.js

@@ -46,21 +46,7 @@ const pageSchema = new mongoose.Schema({
   liker: [{ type: ObjectId, ref: 'User' }],
   seenUsers: [{ type: ObjectId, ref: 'User' }],
   commentCount: { type: Number, default: 0 },
-  extended: {
-    type: String,
-    default: '{}',
-    get(data) {
-      try {
-        return JSON.parse(data);
-      }
-      catch (e) {
-        return data;
-      }
-    },
-    set(data) {
-      return JSON.stringify(data);
-    },
-  },
+  slackChannels: { type: String },
   pageIdOnHackmd: String,
   revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
   hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
@@ -426,33 +412,10 @@ module.exports = function(crowi) {
     return saved;
   };
 
-  pageSchema.methods.getSlackChannel = function() {
-    const extended = this.get('extended');
-    if (!extended) {
-      return '';
-    }
-
-    return extended.slack || '';
-  };
-
-  pageSchema.methods.updateSlackChannel = function(slackChannel) {
-    const extended = this.extended;
-    extended.slack = slackChannel;
-
-    return this.updateExtended(extended);
-  };
+  pageSchema.methods.updateSlackChannels = function(slackChannels) {
+    this.slackChannels = slackChannels;
 
-  pageSchema.methods.updateExtended = function(extended) {
-    const page = this;
-    page.extended = extended;
-    return new Promise(((resolve, reject) => {
-      return page.save((err, doc) => {
-        if (err) {
-          return reject(err);
-        }
-        return resolve(doc);
-      });
-    }));
+    return this.save();
   };
 
   pageSchema.methods.initLatestRevisionField = async function(revisionId) {
@@ -466,7 +429,7 @@ module.exports = function(crowi) {
     validateCrowi();
 
     const User = crowi.model('User');
-    return populateDataToShowRevision(this, User.USER_PUBLIC_FIELDS)
+    return populateDataToShowRevision(this, User.USER_FIELDS_EXCEPT_CONFIDENTIAL)
       .execPopulate();
   };
 
@@ -785,7 +748,7 @@ module.exports = function(crowi) {
     const totalCount = await builder.query.exec('count');
 
     // find
-    builder.populateDataToList(User.USER_PUBLIC_FIELDS);
+    builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
     const pages = await builder.query.exec('find');
 
     const result = {
@@ -828,7 +791,7 @@ module.exports = function(crowi) {
 
     // find
     builder.addConditionToPagenate(opt.offset, opt.limit, sortOpt);
-    builder.populateDataToList(User.USER_PUBLIC_FIELDS);
+    builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
     const pages = await builder.query.exec('find');
 
     const result = {

+ 0 - 57
src/server/models/revision.js

@@ -27,43 +27,6 @@ module.exports = function(crowi) {
   });
   revisionSchema.plugin(mongoosePaginate);
 
-  /*
-   * preparation for https://github.com/weseek/growi/issues/216
-   */
-  // // create a XSS Filter instance
-  // // TODO read options
-  // this.xss = new Xss(true);
-  // // prevent XSS when pre save
-  // revisionSchema.pre('save', function(next) {
-  //   this.body = xss.process(this.body);
-  //   next();
-  // });
-
-  revisionSchema.statics.findRevisions = function(ids) {
-    const Revision = this;
-
-
-    const User = crowi.model('User');
-
-    if (!Array.isArray(ids)) {
-      return Promise.reject(new Error('The argument was not Array.'));
-    }
-
-    return new Promise(((resolve, reject) => {
-      Revision
-        .find({ _id: { $in: ids } })
-        .sort({ createdAt: -1 })
-        .populate('author', User.USER_PUBLIC_FIELDS)
-        .exec((err, revisions) => {
-          if (err) {
-            return reject(err);
-          }
-
-          return resolve(revisions);
-        });
-    }));
-  };
-
   revisionSchema.statics.findRevisionIdList = function(path) {
     return this.find({ path })
       .select('_id author createdAt hasDiffToPrev')
@@ -71,26 +34,6 @@ module.exports = function(crowi) {
       .exec();
   };
 
-  revisionSchema.statics.findRevisionList = function(path, options) {
-    const Revision = this;
-
-
-    const User = crowi.model('User');
-
-    return new Promise(((resolve, reject) => {
-      Revision.find({ path })
-        .sort({ createdAt: -1 })
-        .populate('author', User.USER_PUBLIC_FIELDS)
-        .exec((err, data) => {
-          if (err) {
-            return reject(err);
-          }
-
-          return resolve(data);
-        });
-    }));
-  };
-
   revisionSchema.statics.updateRevisionListByPath = function(path, updateData, options) {
     const Revision = this;
 

+ 1 - 5
src/server/models/user-group-relation.js

@@ -85,14 +85,10 @@ class UserGroupRelation {
    * @memberof UserGroupRelation
    */
   static findAllRelationForUserGroup(userGroup) {
-    const User = UserGroupRelation.crowi.model('User');
     debug('findAllRelationForUserGroup is called', userGroup);
     return this
       .find({ relatedGroup: userGroup })
-      .populate({
-        path: 'relatedUser',
-        select: User.USER_PUBLIC_FIELDS,
-      })
+      .populate('relatedUser')
       .exec();
   }
 

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

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

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

@@ -21,7 +21,7 @@ module.exports = function(crowi) {
   const STATUS_SUSPENDED = 3;
   const STATUS_DELETED = 4;
   const STATUS_INVITED = 5;
-  const USER_PUBLIC_FIELDS = '_id image isEmailPublished isGravatarEnabled googleId name username email introduction'
+  const USER_FIELDS_EXCEPT_CONFIDENTIAL = '_id image isEmailPublished isGravatarEnabled googleId name username email introduction'
   + ' status lang createdAt lastLoginAt admin imageUrlCached';
 
   const PAGE_ITEMS = 50;
@@ -724,7 +724,7 @@ module.exports = function(crowi) {
   userSchema.statics.STATUS_SUSPENDED = STATUS_SUSPENDED;
   userSchema.statics.STATUS_DELETED = STATUS_DELETED;
   userSchema.statics.STATUS_INVITED = STATUS_INVITED;
-  userSchema.statics.USER_PUBLIC_FIELDS = USER_PUBLIC_FIELDS;
+  userSchema.statics.USER_FIELDS_EXCEPT_CONFIDENTIAL = USER_FIELDS_EXCEPT_CONFIDENTIAL;
   userSchema.statics.PAGE_ITEMS = PAGE_ITEMS;
 
   return mongoose.model('User', userSchema);

+ 4 - 5
src/server/routes/apiv3/attachment.js

@@ -6,6 +6,7 @@ const express = require('express');
 
 const router = express.Router();
 const { query } = require('express-validator');
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -69,15 +70,13 @@ module.exports = (crowi) => {
         {
           limit,
           offset,
-          populate: {
-            path: 'creator',
-            select: User.USER_PUBLIC_FIELDS,
-          },
+          populate: 'creator',
         },
       );
+
       paginateResult.docs.forEach((doc) => {
         if (doc.creator != null && doc.creator instanceof User) {
-          doc.creator = doc.creator.toObject();
+          doc.creator = serializeUserSecurely(doc.creator);
         }
       });
 

+ 8 - 1
src/server/routes/apiv3/bookmarks.js

@@ -4,6 +4,7 @@ const logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-
 
 const express = require('express');
 const { body, query, param } = require('express-validator');
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 const router = express.Router();
 
@@ -205,13 +206,19 @@ module.exports = (crowi) => {
             populate: {
               path: 'lastUpdateUser',
               model: 'User',
-              select: User.USER_PUBLIC_FIELDS,
             },
           },
           page,
           limit,
         },
       );
+
+      paginationResult.docs.forEach((doc) => {
+        if (doc.page.lastUpdateUser != null && doc.page.lastUpdateUser instanceof User) {
+          doc.page.lastUpdateUser = serializeUserSecurely(doc.page.lastUpdateUser);
+        }
+      });
+
       return res.apiv3({ paginationResult });
     }
     catch (err) {

+ 2 - 0
src/server/routes/apiv3/index.js

@@ -46,5 +46,7 @@ module.exports = (crowi) => {
   router.use('/bookmarks', require('./bookmarks')(crowi));
   router.use('/attachment', require('./attachment')(crowi));
 
+  router.use('/staffs', require('./staffs')(crowi));
+
   return router;
 };

+ 17 - 2
src/server/routes/apiv3/pages.js

@@ -112,6 +112,7 @@ module.exports = (crowi) => {
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const Page = crowi.model('Page');
+  const User = crowi.model('User');
   const PageTagRelation = crowi.model('PageTagRelation');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
 
@@ -120,6 +121,7 @@ module.exports = (crowi) => {
 
   const { serializePageSecurely } = require('../../models/serializers/page-serializer');
   const { serializeRevisionSecurely } = require('../../models/serializers/revision-serializer');
+  const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
   const validator = {
     createPage: [
@@ -252,7 +254,7 @@ module.exports = (crowi) => {
     // user notification
     if (isSlackEnabled) {
       try {
-        const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create', false);
+        const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
         results.forEach((result) => {
           if (result.status === 'rejected') {
             logger.error('Create user notification failed', result.reason);
@@ -299,6 +301,12 @@ module.exports = (crowi) => {
         result.pages.pop();
       }
 
+      result.pages.forEach((page) => {
+        if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
+          page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
+        }
+      });
+
       return res.apiv3(result);
     }
     catch (err) {
@@ -436,7 +444,7 @@ module.exports = (crowi) => {
     const options = { socketClientId };
 
     try {
-      const pages = await crowi.pageService.deletePageRecursivelyCompletely({ path: '/trash' }, req.user, options);
+      const pages = await crowi.pageService.deleteCompletelyDescendantsWithStream({ path: '/trash' }, req.user, options);
       return res.apiv3({ pages });
     }
     catch (err) {
@@ -470,6 +478,13 @@ module.exports = (crowi) => {
 
     try {
       const result = await Page.findListWithDescendants(path, req.user, queryOptions);
+
+      result.pages.forEach((page) => {
+        if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
+          page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
+        }
+      });
+
       return res.apiv3(result);
     }
     catch (err) {

+ 14 - 5
src/server/routes/apiv3/revisions.js

@@ -5,6 +5,7 @@ const logger = loggerFactory('growi:routes:apiv3:pages');
 const express = require('express');
 
 const { query, param } = require('express-validator');
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const router = express.Router();
@@ -128,13 +129,16 @@ module.exports = (crowi) => {
           page: selectedPage,
           limit,
           sort: { createdAt: -1 },
-          populate: {
-            path: 'author',
-            select: User.USER_PUBLIC_FIELDS,
-          },
+          populate: 'author',
         },
       );
 
+      paginateResult.docs.forEach((doc) => {
+        if (doc.author != null && doc.author instanceof User) {
+          doc.author = serializeUserSecurely(doc.author);
+        }
+      });
+
       return res.apiv3(paginateResult);
     }
     catch (err) {
@@ -181,7 +185,12 @@ module.exports = (crowi) => {
     }
 
     try {
-      const revision = await Revision.findById(revisionId).populate('author', User.USER_PUBLIC_FIELDS);
+      const revision = await Revision.findById(revisionId).populate('author');
+
+      if (revision.author != null && revision.author instanceof User) {
+        revision.author = serializeUserSecurely(revision.author);
+      }
+
       return res.apiv3({ revision });
     }
     catch (err) {

+ 52 - 0
src/server/routes/apiv3/staffs.js

@@ -0,0 +1,52 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:staffs'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+
+const axios = require('axios');
+
+const router = express.Router();
+const { isAfter, addHours } = require('date-fns');
+
+const contributors = require('../../../../resource/Contributor');
+
+let expiredAt;
+const contributorsCache = contributors;
+let gcContributors;
+
+// Sorting contributors by this method
+const compareFunction = function(a, b) {
+  return a.order - b.order;
+};
+
+module.exports = (crowi) => {
+
+  router.get('/', async(req, res) => {
+    const now = new Date();
+    const growiCloudUri = await crowi.configManager.getConfig('crowi', 'app:growiCloudUri');
+
+    if (growiCloudUri != null && (expiredAt == null || isAfter(now, expiredAt))) {
+      const url = new URL('_api/staffCredit', growiCloudUri);
+      try {
+        const gcContributorsRes = await axios.get(url.toString());
+        if (gcContributors == null) {
+          gcContributors = gcContributorsRes.data;
+          // merging contributors
+          contributorsCache.push(gcContributors);
+        }
+        // Change the order of section
+        contributorsCache.sort(compareFunction);
+        // caching 'expiredAt' for 1 hour
+        expiredAt = addHours(now, 1);
+      }
+      catch (err) {
+        logger.warn('Getting GROWI.cloud staffcredit is failed');
+      }
+    }
+    return res.apiv3({ contributors: contributorsCache });
+  });
+
+  return router;
+
+};

+ 2 - 1
src/server/routes/apiv3/user-group-relation.js

@@ -4,6 +4,7 @@ const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslin
 
 const express = require('express');
 
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const router = express.Router();
@@ -52,7 +53,7 @@ module.exports = (crowi) => {
       await Promise.all(userGroups.map(async(userGroup) => {
         const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
         userGroupRelationsObj[userGroup._id] = userGroupRelations.map((userGroupRelation) => {
-          return userGroupRelation.relatedUser;
+          return serializeUserSecurely(userGroupRelation.relatedUser);
         });
       }));
 

+ 23 - 17
src/server/routes/apiv3/user-group.js

@@ -13,6 +13,7 @@ const mongoose = require('mongoose');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const { toPagingLimit, toPagingOffset } = require('../../util/express-validator/sanitizer');
 
 const validator = {};
@@ -174,7 +175,7 @@ module.exports = (crowi) => {
     const { actionName, transferToUserGroupId } = req.query;
 
     try {
-      const userGroup = await UserGroup.removeCompletelyById(deleteGroupId, actionName, transferToUserGroupId);
+      const userGroup = await UserGroup.removeCompletelyById(deleteGroupId, actionName, transferToUserGroupId, req.user);
 
       return res.apiv3({ userGroup });
     }
@@ -288,7 +289,7 @@ module.exports = (crowi) => {
       const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
 
       const users = userGroupRelations.map((userGroupRelation) => {
-        return userGroupRelation.relatedUser;
+        return serializeUserSecurely(userGroupRelation.relatedUser);
       });
 
       return res.apiv3({ users });
@@ -344,7 +345,14 @@ module.exports = (crowi) => {
       const userGroup = await UserGroup.findById(id);
       const users = await UserGroupRelation.findUserByNotRelatedGroup(userGroup, queryOptions);
 
-      return res.apiv3({ users });
+      // return email only this api
+      const serializedUsers = users.map((user) => {
+        const { email } = user;
+        const serializedUser = serializeUserSecurely(user);
+        serializedUser.email = email;
+        return serializedUser;
+      });
+      return res.apiv3({ users: serializedUsers });
     }
     catch (err) {
       const msg = `Error occurred in fetching unrelated users for group: ${id}`;
@@ -411,9 +419,9 @@ module.exports = (crowi) => {
       }
 
       const userGroupRelation = await UserGroupRelation.createRelation(userGroup, user);
-      await userGroupRelation.populate('relatedUser', User.USER_PUBLIC_FIELDS).execPopulate();
+      const serializedUser = serializeUserSecurely(user);
 
-      return res.apiv3({ user, userGroup, userGroupRelation });
+      return res.apiv3({ user: serializedUser, userGroup, userGroupRelation });
     }
     catch (err) {
       const msg = `Error occurred in adding the user "${username}" to group "${id}"`;
@@ -471,14 +479,10 @@ module.exports = (crowi) => {
         User.findUserByUsername(username),
       ]);
 
-      const userGroupRelation = await UserGroupRelation.findOne({ relatedUser: new ObjectId(user._id), relatedGroup: new ObjectId(userGroup._id) });
-      if (userGroupRelation == null) {
-        throw new Error(`Group "${id}" does not exist or user "${username}" does not belong to group "${id}"`);
-      }
-
-      await userGroupRelation.remove();
+      const userGroupRelation = await UserGroupRelation.findOneAndDelete({ relatedUser: new ObjectId(user._id), relatedGroup: new ObjectId(userGroup._id) });
+      const serializedUser = serializeUserSecurely(user);
 
-      return res.apiv3({ user, userGroup, userGroupRelation });
+      return res.apiv3({ user: serializedUser, userGroup, userGroupRelation });
     }
     catch (err) {
       const msg = `Error occurred in removing the user "${username}" from group "${id}"`;
@@ -584,16 +588,18 @@ module.exports = (crowi) => {
       }, {
         offset,
         limit,
-        populate: {
-          path: 'lastUpdateUser',
-          select: User.USER_PUBLIC_FIELDS,
-        },
+        populate: 'lastUpdateUser',
       });
 
       const current = offset / limit + 1;
 
+      const pages = docs.map((doc) => {
+        doc.lastUpdateUser = serializeUserSecurely(doc.lastUpdateUser);
+        return doc;
+      });
+
       // TODO: create a common moudule for paginated response
-      return res.apiv3({ total: totalDocs, current, pages: docs });
+      return res.apiv3({ total: totalDocs, current, pages });
     }
     catch (err) {
       const msg = `Error occurred in fetching pages for group: ${id}`;

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

@@ -188,7 +188,7 @@ module.exports = function(crowi, app) {
     const user = req.user;
     const isAccessible = await isAccessibleByViewer(user, attachment);
     if (!isAccessible) {
-      return res.json(ApiResponse.error(`Forbidden to access to the attachment '${attachment.id}'`));
+      return res.json(ApiResponse.error(`Forbidden to access to the attachment '${attachment.id}'. This attachment might belong to other pages.`));
     }
 
     // add headers before evaluating 'req.fresh'

+ 23 - 27
src/server/routes/comment.js

@@ -4,6 +4,8 @@
  *    name: Comments
  */
 
+const { serializeUserSecurely } = require('../models/serializers/user-serializer');
+
 /**
  * @swagger
  *
@@ -48,7 +50,10 @@ module.exports = function(crowi, app) {
   const Page = crowi.model('Page');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const ApiResponse = require('../util/apiResponse');
+
   const globalNotificationService = crowi.getGlobalNotificationService();
+  const userNotificationService = crowi.getUserNotificationService();
+
   const { body } = require('express-validator');
   const mongoose = require('mongoose');
   const ObjectId = mongoose.Types.ObjectId;
@@ -127,9 +132,12 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(err));
     }
 
-    const comments = await fetcher.populate(
-      { path: 'creator', select: User.USER_PUBLIC_FIELDS },
-    );
+    const comments = await fetcher.populate('creator');
+    comments.forEach((comment) => {
+      if (comment.creator != null && comment.creator instanceof User) {
+        comment.creator = serializeUserSecurely(comment.creator);
+      }
+    });
 
     res.json(ApiResponse.success({ comments }));
   };
@@ -231,11 +239,6 @@ module.exports = function(crowi, app) {
     let createdComment;
     try {
       createdComment = await Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown, replyTo);
-
-      await Comment.populate(createdComment, [
-        { path: 'creator', model: 'User', select: User.USER_PUBLIC_FIELDS },
-      ]);
-
     }
     catch (err) {
       logger.error(err);
@@ -253,8 +256,6 @@ module.exports = function(crowi, app) {
 
     res.json(ApiResponse.success({ comment: createdComment }));
 
-    const path = page.path;
-
     // global notification
     try {
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.COMMENT, page, req.user, {
@@ -265,26 +266,21 @@ module.exports = function(crowi, app) {
       logger.error('Comment notification failed', err);
     }
 
-
     // slack notification
     if (slackNotificationForm.isSlackEnabled) {
-      const user = await User.findUserByUsername(req.user.username);
-      const channelsStr = slackNotificationForm.slackChannels || null;
-
-      page.updateSlackChannel(channelsStr).catch((err) => {
-        logger.error('Error occured in updating slack channels: ', err);
-      });
-
-      const channels = channelsStr != null ? channelsStr.split(',') : [null];
-
-      const promises = channels.map((chan) => {
-        return crowi.slack.postComment(createdComment, user, chan, path);
-      });
-
-      Promise.all(promises)
-        .catch((err) => {
-          logger.error('Error occured in sending slack notification: ', err);
+      const { slackChannels } = slackNotificationForm;
+
+      try {
+        const results = await userNotificationService.fire(page, req.user, slackChannels, 'comment', {}, createdComment);
+        results.forEach((result) => {
+          if (result.status === 'rejected') {
+            logger.error('Create user notification failed', result.reason);
+          }
         });
+      }
+      catch (err) {
+        logger.error('Create user notification failed', err);
+      }
     }
   };
 

+ 0 - 1
src/server/routes/index.js

@@ -133,7 +133,6 @@ module.exports = function(crowi, app) {
 
   app.get('/_api/check_username'           , user.api.checkUsername);
   app.get('/_api/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
-  app.get('/_api/user/bookmarks'           , loginRequired , user.api.bookmarks);
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   app.get('/_api/users.list'          , accessTokenParser , loginRequired , user.api.list);

+ 34 - 56
src/server/routes/page.js

@@ -1,5 +1,6 @@
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
+const { serializeUserSecurely } = require('../models/serializers/user-serializer');
 
 /**
  * @swagger
@@ -134,18 +135,17 @@ module.exports = function(crowi, app) {
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');
-  const Bookmark = crowi.model('Bookmark');
   const PageTagRelation = crowi.model('PageTagRelation');
-  const UpdatePost = crowi.model('UpdatePost');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const ShareLink = crowi.model('ShareLink');
 
   const ApiResponse = require('../util/apiResponse');
   const getToday = require('../util/getToday');
 
-  const { slackNotificationService, configManager, xssService } = crowi;
+  const { configManager, xssService } = crowi;
   const interceptorManager = crowi.getInterceptorManager();
   const globalNotificationService = crowi.getGlobalNotificationService();
+  const userNotificationService = crowi.getUserNotificationService();
 
   const XssOption = require('../../lib/service/xss/xssOption');
   const Xss = require('../../lib/service/xss/index');
@@ -194,37 +194,6 @@ module.exports = function(crowi, app) {
     };
   }
 
-  // user notification
-  // TODO create '/service/user-notification' module
-  /**
-   *
-   * @param {Page} page
-   * @param {User} user
-   * @param {string} slackChannelsStr comma separated string. e.g. 'general,channel1,channel2'
-   * @param {boolean} updateOrCreate
-   * @param {string} previousRevision
-   */
-  async function notifyToSlackByUser(page, user, slackChannelsStr, updateOrCreate, previousRevision) {
-    await page.updateSlackChannel(slackChannelsStr)
-      .catch((err) => {
-        logger.error('Error occured in updating slack channels: ', err);
-      });
-
-
-    if (slackNotificationService.hasSlackConfig()) {
-      const slackChannels = slackChannelsStr != null ? slackChannelsStr.split(',') : [null];
-
-      const promises = slackChannels.map((chan) => {
-        return crowi.slack.postPage(page, user, chan, updateOrCreate, previousRevision);
-      });
-
-      Promise.all(promises)
-        .catch((err) => {
-          logger.error('Error occured in sending slack notification: ', err);
-        });
-    }
-  }
-
   function addRenderVarsForPage(renderVars, page) {
     renderVars.page = page;
     renderVars.revision = page.revision;
@@ -253,12 +222,11 @@ module.exports = function(crowi, app) {
     renderVars.revision = page.revision;
   }
 
-  async function addRenderVarsForUserPage(renderVars, page, requestUser) {
+  async function addRenderVarsForUserPage(renderVars, page) {
     const userData = await User.findUserByUsername(User.getUsernameByPath(page.path));
 
     if (userData != null) {
-      renderVars.pageUser = userData.toObject();
-      renderVars.bookmarkList = await Bookmark.findByUser(userData, { limit: 10, populatePage: true, requestUser });
+      renderVars.pageUser = serializeUserSecurely(userData);
     }
   }
 
@@ -268,10 +236,6 @@ module.exports = function(crowi, app) {
     renderVars.grantedGroupName = page.grantedGroup ? page.grantedGroup.name : null;
   }
 
-  async function addRenderVarsForSlack(renderVars, page) {
-    renderVars.slack = await getSlackChannels(page);
-  }
-
   async function addRenderVarsForDescendants(renderVars, path, requestUser, offset, limit, isRegExpEscapedFromPath) {
     const SEENER_THRESHOLD = 10;
 
@@ -349,7 +313,6 @@ module.exports = function(crowi, app) {
     portalPage = await portalPage.populateDataToShowRevision();
 
     addRenderVarsForPage(renderVars, portalPage);
-    await addRenderVarsForSlack(renderVars, portalPage);
 
     const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: portalPage._id });
     renderVars.sharelinksNumber = sharelinksNumber;
@@ -399,7 +362,6 @@ module.exports = function(crowi, app) {
     addRenderVarsForPage(renderVars, page);
     addRenderVarsForScope(renderVars, page);
 
-    await addRenderVarsForSlack(renderVars, page);
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
 
     const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: page._id });
@@ -408,23 +370,13 @@ module.exports = function(crowi, app) {
     if (isUserPage(page.path)) {
       // change template
       view = 'layout-growi/user_page';
-      await addRenderVarsForUserPage(renderVars, page, req.user);
+      await addRenderVarsForUserPage(renderVars, page);
     }
 
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
   }
 
-  const getSlackChannels = async(page) => {
-    if (page.extended.slack) {
-      return page.extended.slack;
-    }
-
-    const data = await UpdatePost.findSettingsByPath(page.path);
-    const channels = data.map((e) => { return e.channel }).join(', ');
-    return channels;
-  };
-
   actions.showTopPage = function(req, res) {
     return showTopPage(req, res);
   };
@@ -701,6 +653,12 @@ module.exports = function(crowi, app) {
         result.pages.pop();
       }
 
+      result.pages.forEach((page) => {
+        if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
+          page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
+        }
+      });
+
       return res.json(ApiResponse.success(result));
     }
     catch (err) {
@@ -769,7 +727,17 @@ module.exports = function(crowi, app) {
 
     // user notification
     if (isSlackEnabled) {
-      await notifyToSlackByUser(createdPage, req.user, slackChannels, 'create', false);
+      try {
+        const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
+        results.forEach((result) => {
+          if (result.status === 'rejected') {
+            logger.error('Create user notification failed', result.reason);
+          }
+        });
+      }
+      catch (err) {
+        logger.error('Create user notification failed', err);
+      }
     }
   };
 
@@ -904,7 +872,17 @@ module.exports = function(crowi, app) {
 
     // user notification
     if (isSlackEnabled) {
-      await notifyToSlackByUser(page, req.user, slackChannels, 'update', previousRevision);
+      try {
+        const results = await userNotificationService.fire(page, req.user, slackChannels, 'update', { previousRevision });
+        results.forEach((result) => {
+          if (result.status === 'rejected') {
+            logger.error('Create user notification failed', result.reason);
+          }
+        });
+      }
+      catch (err) {
+        logger.error('Create user notification failed', err);
+      }
     }
   };
 

+ 6 - 0
src/server/routes/search.js

@@ -1,3 +1,5 @@
+const { serializeUserSecurely } = require('../models/serializers/user-serializer');
+
 /**
  * @swagger
  *
@@ -27,6 +29,7 @@
 module.exports = function(crowi, app) {
   // var debug = require('debug')('growi:routes:search')
   const Page = crowi.model('Page');
+  const User = crowi.model('User');
   const ApiResponse = require('../util/apiResponse');
   const ApiPaginate = require('../util/apiPaginate');
 
@@ -159,6 +162,9 @@ module.exports = function(crowi, app) {
       result.totalCount = findResult.totalCount;
       result.data = findResult.pages
         .map((page) => {
+          if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
+            page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
+          }
           page.bookmarkCount = (page._source && page._source.bookmark_count) || 0;
           return page;
         })

+ 0 - 11
src/server/routes/user.js

@@ -47,7 +47,6 @@
 
 module.exports = function(crowi, app) {
   const User = crowi.model('User');
-  const Bookmark = crowi.model('Bookmark');
   const ApiResponse = require('../util/apiResponse');
 
   const actions = {};
@@ -57,16 +56,6 @@ module.exports = function(crowi, app) {
 
   actions.api = api;
 
-  api.bookmarks = function(req, res) {
-    const options = {
-      skip: req.query.offset || 0,
-      limit: req.query.limit || 50,
-    };
-    Bookmark.findByUser(req.user, options, (err, bookmarks) => {
-      res.json(bookmarks);
-    });
-  };
-
   api.checkUsername = function(req, res) {
     const username = req.query.username;
 

+ 3 - 3
src/server/service/page.js

@@ -102,7 +102,7 @@ class PageService {
       const revisionId = new mongoose.Types.ObjectId();
 
       if (updateMetadata) {
-        unorderedBulkOp.find({ _id: page._id }).update([{ $set: { path: newPagePath, lastUpdateUser: user._id, updatedAt: { $toDate: Date.now() } } }]);
+        unorderedBulkOp.find({ _id: page._id }).update({ $set: { path: newPagePath, lastUpdateUser: user._id, updatedAt:  Date.now() } });
       }
       else {
         unorderedBulkOp.find({ _id: page._id }).update({ $set: { path: newPagePath } });
@@ -721,7 +721,7 @@ class PageService {
   }
 
 
-  async handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId) {
+  async handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId, user) {
     const Page = this.crowi.model('Page');
     const pages = await Page.find({ grantedGroup: deletedGroup });
 
@@ -732,7 +732,7 @@ class PageService {
         }));
         break;
       case 'delete':
-        return this.deleteMultiplePagesCompletely(pages);
+        return this.deleteMultipleCompletely(pages, user);
       case 'transfer':
         await Promise.all(pages.map((page) => {
           return Page.transferPageToGroup(page, transferToUserGroupId);

+ 1 - 1
src/server/service/search-delegator/elasticsearch.js

@@ -390,7 +390,7 @@ class ElasticsearchDelegator {
         { path: 'revision', model: 'Revision', select: 'body' },
       ])
       .lean()
-      .cursor({ batchSize: BULK_REINDEX_SIZE });
+      .cursor();
 
     let skipped = 0;
     const thinOutStream = new Transform({

+ 14 - 4
src/server/service/user-notification/index.js

@@ -19,13 +19,17 @@ class UserNotificationService {
    * @param {Page} page
    * @param {User} user
    * @param {string} slackChannelsStr comma separated string. e.g. 'general,channel1,channel2'
-   * @param {boolean} updateOrCreate
+   * @param {string} mode 'create' or 'update' or 'comment'
    * @param {string} previousRevision
+   * @param {Comment} comment
    */
-  async fire(page, user, slackChannelsStr, updateOrCreate, previousRevision) {
+  async fire(page, user, slackChannelsStr, mode, option, comment = {}) {
     const { slackNotificationService, slack } = this.crowi;
 
-    await page.updateSlackChannel(slackChannelsStr);
+    const opt = option || {};
+    const previousRevision = opt.previousRevision || '';
+
+    await page.updateSlackChannels(slackChannelsStr);
 
     if (!slackNotificationService.hasSlackConfig()) {
       throw new Error('slackNotificationService has not been set up');
@@ -35,7 +39,13 @@ class UserNotificationService {
     const slackChannels = toArrayFromCsv(slackChannelsStr);
 
     const promises = slackChannels.map(async(chan) => {
-      const res = await slack.postPage(page, user, chan, updateOrCreate, previousRevision);
+      let res;
+      if (mode === 'comment') {
+        res = await slack.postComment(comment, user, chan, page.path);
+      }
+      else {
+        res = await slack.postPage(page, user, chan, mode, previousRevision);
+      }
       if (res.status !== 'ok') {
         throw new Error(`fail to send slack notification to #${chan} channel`);
       }

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

@@ -18,7 +18,7 @@
   data-page-is-deletable="{% if isDeletablePage() %}true{% else %}false{% endif %}"
   data-page-is-not-creatable="false"
   data-page-is-able-to-delete-completely="{% if user.canDeleteCompletely(page.creator._id) %}true{% else %}false{% endif %}"
-  data-slack-channels="{{ slack|default('') }}"
+  data-slack-channels="{% if page %}{{ page.slackChannels }}{% endif %}"
   data-page-created-at="{% if page %}{{ page.createdAt|datetz('Y/m/d H:i:s') }}{% 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 %}"
@@ -36,7 +36,7 @@
 <div id="content-main" class="content-main d-flex"
   data-path="{{ encodeURI(path) }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-  data-slack-channels="{{ slack|default('') }}"
+  data-slack-channels="{% if page %}{{ page.slackChannels }}{% endif %}"
   data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   >

+ 4 - 23
src/test/models/page.test.js

@@ -93,10 +93,9 @@ describe('Page', () => {
         creator: testUser0,
       },
       {
-        path: '/page/for/extended',
+        path: '/page/child/without/parents',
         grant: Page.GRANT_PUBLIC,
         creator: testUser0,
-        extended: { hoge: 1 },
       },
       {
         path: '/grant/groupacl',
@@ -266,24 +265,6 @@ describe('Page', () => {
     });
   });
 
-  describe('Extended field', () => {
-    describe('Slack Channel.', () => {
-      test('should be empty', async() => {
-        const page = await Page.findOne({ path: '/page/for/extended' });
-        expect(page.extended.hoge).toEqual(1);
-        expect(page.getSlackChannel()).toEqual('');
-      });
-
-      test('set slack channel and should get it and should keep hoge ', async() => {
-        let page = await Page.findOne({ path: '/page/for/extended' });
-        await page.updateSlackChannel('slack-channel1');
-        page = await Page.findOne({ path: '/page/for/extended' });
-        expect(page.extended.hoge).toEqual(1);
-        expect(page.getSlackChannel()).toEqual('slack-channel1');
-      });
-    });
-  });
-
   describe('.findPage', () => {
     describe('findByIdAndViewer', () => {
       test('should find page (public)', async() => {
@@ -341,7 +322,7 @@ describe('Page', () => {
       expect(result.length).toEqual(1);
       // assert paths
       const pagePaths = result.map((page) => { return page.path });
-      expect(pagePaths).toContainEqual('/page/for/extended');
+      expect(pagePaths).toContainEqual('/page/child/without/parents');
     });
 
     test('can retrieve descendants of /page1', async() => {
@@ -370,7 +351,7 @@ describe('Page', () => {
       expect(result.length).toEqual(1);
       // assert paths
       const pagePaths = result.map((page) => { return page.path });
-      expect(pagePaths).toContainEqual('/page/for/extended');
+      expect(pagePaths).toContainEqual('/page/child/without/parents');
     });
 
     test('can retrieve only descendants of /page1', async() => {
@@ -398,7 +379,7 @@ describe('Page', () => {
       expect(result.length).toEqual(4);
       // assert paths
       const pagePaths = result.map((page) => { return page.path });
-      expect(pagePaths).toContainEqual('/page/for/extended');
+      expect(pagePaths).toContainEqual('/page/child/without/parents');
       expect(pagePaths).toContainEqual('/page1');
       expect(pagePaths).toContainEqual('/page1/child1');
       expect(pagePaths).toContainEqual('/page2');