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

Merge branch 'dev/7.0.x' into feat/126519-136101-support-scroll-sync

reiji-h 2 лет назад
Родитель
Сommit
27f10748df
56 измененных файлов с 904 добавлено и 566 удалено
  1. 3 3
      .vscode/settings.json
  2. 16 1
      CHANGELOG.md
  3. 2 1
      apps/app/docker/README.md
  4. 3 2
      apps/app/public/static/locales/en_US/admin.json
  5. 1 1
      apps/app/public/static/locales/en_US/commons.json
  6. 7 0
      apps/app/public/static/locales/en_US/translation.json
  7. 3 2
      apps/app/public/static/locales/ja_JP/admin.json
  8. 1 1
      apps/app/public/static/locales/ja_JP/commons.json
  9. 7 0
      apps/app/public/static/locales/ja_JP/translation.json
  10. 4 3
      apps/app/public/static/locales/zh_CN/admin.json
  11. 1 1
      apps/app/public/static/locales/zh_CN/commons.json
  12. 7 0
      apps/app/public/static/locales/zh_CN/translation.json
  13. 10 0
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  14. 14 1
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  15. 5 3
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  16. 15 0
      apps/app/src/components/Me/ColorModeSettings.module.scss
  17. 62 0
      apps/app/src/components/Me/ColorModeSettings.tsx
  18. 6 1
      apps/app/src/components/Me/OtherSettings.tsx
  19. 30 62
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  20. 1 5
      apps/app/src/components/PageAlert/PageGrantAlert.tsx
  21. 6 21
      apps/app/src/components/PageEditor/PageEditor.tsx
  22. 12 34
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  23. 4 1
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  24. 1 2
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  25. 6 4
      apps/app/src/components/TreeItem/SimpleItem.tsx
  26. 1 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx
  27. 6 6
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx
  28. 1 1
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  29. 2 2
      apps/app/src/interfaces/page-grant.ts
  30. 14 21
      apps/app/src/pages/[[...path]].page.tsx
  31. 18 18
      apps/app/src/server/events/user.ts
  32. 2 1
      apps/app/src/server/models/config.ts
  33. 4 0
      apps/app/src/server/models/page.ts
  34. 246 1
      apps/app/src/server/routes/apiv3/attachment.js
  35. 5 0
      apps/app/src/server/routes/apiv3/page.js
  36. 13 9
      apps/app/src/server/routes/apiv3/pages.js
  37. 12 3
      apps/app/src/server/routes/apiv3/security-settings/index.js
  38. 5 10
      apps/app/src/server/routes/apiv3/users.js
  39. 0 162
      apps/app/src/server/routes/attachment/api.js
  40. 0 2
      apps/app/src/server/routes/index.js
  41. 35 1
      apps/app/src/server/routes/page.js
  42. 13 14
      apps/app/src/server/service/file-uploader/aws.ts
  43. 45 52
      apps/app/src/server/service/file-uploader/azure.ts
  44. 10 8
      apps/app/src/server/service/file-uploader/gcs.ts
  45. 5 1
      apps/app/src/server/service/file-uploader/gridfs.ts
  46. 3 3
      apps/app/src/server/service/file-uploader/local.ts
  47. 8 8
      apps/app/src/server/service/page-grant.ts
  48. 111 23
      apps/app/src/server/service/page.ts
  49. 50 39
      apps/app/test/integration/service/external-user-group-sync.test.ts
  50. 16 16
      apps/app/test/integration/service/page-grant.test.js
  51. 1 1
      package.json
  52. 0 1
      packages/core/src/interfaces/page.ts
  53. 20 2
      packages/core/src/utils/page-path-utils/index.spec.ts
  54. 0 7
      packages/remark-drawio/src/components/DrawioViewer.module.scss
  55. 27 1
      packages/remark-drawio/src/components/DrawioViewer.tsx
  56. 4 4
      yarn.lock

+ 3 - 3
.vscode/settings.json

@@ -12,9 +12,9 @@
   "scss.validate": false,
   "scss.validate": false,
 
 
   "editor.codeActionsOnSave": {
   "editor.codeActionsOnSave": {
-    "source.fixAll.eslint": true,
-    "source.fixAll.markdownlint": true,
-    "source.fixAll.stylelint": true
+    "source.fixAll.eslint": "explicit",
+    "source.fixAll.markdownlint": "explicit",
+    "source.fixAll.stylelint": "explicit"
   },
   },
 
 
   "githubPullRequests.ignoredPullRequestBranches": [
   "githubPullRequests.ignoredPullRequestBranches": [

+ 16 - 1
CHANGELOG.md

@@ -1,9 +1,24 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.2.3...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.2.4...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v6.2.4](https://github.com/weseek/growi/compare/v6.2.3...v6.2.4) - 2023-11-29
+
+### 💎 Features
+* feat: Show create date in Attachment Data list (#8229) @sakazuki
+
+### 🚀 Improvement
+
+* imprv: Add Marp preset template for ja_JP and zh_CN (#8179) @AikaHiyama
+* imprv: Allow deletion of user homepage when the user is deleted (#8224) @jam411
+
+### 🧰 Maintenance
+
+* support: Refactor deleteCompletelyUserHomeBySystem (#8262) @jam411
+
+
 ## [v6.2.3](https://github.com/weseek/growi/compare/v6.2.2...v6.2.3) - 2023-11-13
 ## [v6.2.3](https://github.com/weseek/growi/compare/v6.2.2...v6.2.3) - 2023-11-13
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement

+ 2 - 1
apps/app/docker/README.md

@@ -11,7 +11,8 @@ Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
 * [`7.0.0`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.0/apps/app/docker/Dockerfile)
 * [`7.0.0`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.0/apps/app/docker/Dockerfile)
-* [`6.2.3`, `6.2`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.3/apps/app/docker/Dockerfile)
+* [`6.3.0`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.0/apps/app/docker/Dockerfile)
+* [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)
 
 
 
 

+ 3 - 2
apps/app/public/static/locales/en_US/admin.json

@@ -48,8 +48,9 @@
     "anyone": "Anyone",
     "anyone": "Anyone",
     "user_homepage_deletion": {
     "user_homepage_deletion": {
       "user_homepage_deletion": "User homepage deletion",
       "user_homepage_deletion": "User homepage deletion",
-      "enable_user_homepage_deletion": "Complete deletion of user homepage, when user deletion",
-      "desc": "When deleting a user, the user homepage and its sub pages are also completely deleted."
+      "enable_user_homepage_deletion": "Enable user homepage deletion",
+      "enable_force_delete_user_homepage_on_user_deletion": "When you delete a user, the user's homepage and all its sub pages will be completely deleted",
+      "desc": "You will be able to delete a deleted user's homepage."
     },
     },
     "session": "Session",
     "session": "Session",
     "max_age": "Max age (msec)",
     "max_age": "Max age (msec)",

+ 1 - 1
apps/app/public/static/locales/en_US/commons.json

@@ -71,7 +71,7 @@
   "create_page_dropdown": {
   "create_page_dropdown": {
     "new_page": "Create New Page",
     "new_page": "Create New Page",
     "todays": {
     "todays": {
-      "desc": "Create today's ...",
+      "desc": "Create today's memo",
       "memo": "memo"
       "memo": "memo"
     },
     },
     "template": {
     "template": {

+ 7 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -257,6 +257,13 @@
       "description": "You can set whether or not the sidebar will always be open when the screen width is large. If the screen width is small, the sidebar will always be closed."
       "description": "You can set whether or not the sidebar will always be open when the screen width is large. If the screen width is small, the sidebar will always be closed."
     }
     }
   },
   },
+  "color_mode_settings": {
+    "light": "Light",
+    "dark": "Dark",
+    "system": "System",
+    "settings": "Color mode settings",
+    "description": "Select whether to display in light mode, dark mode, or a system-specific display.<br>Only supported themes can be switched."
+  },
   "editor_settings": {
   "editor_settings": {
     "editor_settings": "Editor Settings"
     "editor_settings": "Editor Settings"
   },
   },

+ 3 - 2
apps/app/public/static/locales/ja_JP/admin.json

@@ -57,8 +57,9 @@
     "anyone": "誰でも可能",
     "anyone": "誰でも可能",
     "user_homepage_deletion": {
     "user_homepage_deletion": {
       "user_homepage_deletion": "ユーザーホームページの削除",
       "user_homepage_deletion": "ユーザーホームページの削除",
-      "enable_user_homepage_deletion": "ユーザー削除時にユーザーホームページを完全削除する",
-      "desc": "ユーザーを削除する際に、ユーザーホームページとその配下のページも完全削除されます。"
+      "enable_user_homepage_deletion": "ユーザーホームページの削除を有効化",
+      "enable_force_delete_user_homepage_on_user_deletion": "ユーザーを削除したとき、ユーザーホームページとその配下のページを完全削除する",
+      "desc": "削除済みユーザーのユーザーホームページを削除できるようになります。"
     },
     },
     "session": "セッション",
     "session": "セッション",
     "max_age": "有効期間 (ミリ秒)",
     "max_age": "有効期間 (ミリ秒)",

+ 1 - 1
apps/app/public/static/locales/ja_JP/commons.json

@@ -73,7 +73,7 @@
   "create_page_dropdown": {
   "create_page_dropdown": {
     "new_page": "新規ページ作成",
     "new_page": "新規ページ作成",
     "todays": {
     "todays": {
-      "desc": "今日の◯◯を作成",
+      "desc": "今日のメモを作成",
       "memo": "メモ"
       "memo": "メモ"
     },
     },
     "template": {
     "template": {

+ 7 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -258,6 +258,13 @@
       "description": "画面幅が大きい場合に、サイドバーを常時開いた状態にするかどうかを設定できます。画面幅が小さい場合はサイドバーは常に閉じた状態となります。"
       "description": "画面幅が大きい場合に、サイドバーを常時開いた状態にするかどうかを設定できます。画面幅が小さい場合はサイドバーは常に閉じた状態となります。"
     }
     }
   },
   },
+  "color_mode_settings": {
+    "light": "ライト",
+    "dark": "ダーク",
+    "system": "システム",
+    "settings": "カラーモードの設定",
+    "description": "ライトモードかダークモード、もしくはシステム合わせた表示をするか選択します。<br>対応したテーマのみ切り替えることができます。"
+  },
   "editor_settings": {
   "editor_settings": {
     "editor_settings": "エディター設定",
     "editor_settings": "エディター設定",
     "common_settings": {
     "common_settings": {

+ 4 - 3
apps/app/public/static/locales/zh_CN/admin.json

@@ -56,9 +56,10 @@
 		"admin_and_author": "管理员|作者",
 		"admin_and_author": "管理员|作者",
 		"anyone": "任何人",
 		"anyone": "任何人",
     "user_homepage_deletion": {
     "user_homepage_deletion": {
-      "user_homepage_deletion": "删除用户页面",
-      "enable_user_homepage_deletion": "用户删除时,完全删除用户主页",
-      "desc": "删除用户时,用户主页及其下属页面也会被完全删除。"
+      "user_homepage_deletion": "删除用户主页",
+      "enable_user_homepage_deletion": "启用用户主页删除功能",
+      "enable_force_delete_user_homepage_on_user_deletion": "删除用户时,该用户的主页及其所有子页面将被完全删除",
+      "desc": "您可以删除已删除用户的主页。"
     },
     },
     "session": "会议",
     "session": "会议",
     "max_age": "有效期间  (msec)",
     "max_age": "有效期间  (msec)",

+ 1 - 1
apps/app/public/static/locales/zh_CN/commons.json

@@ -74,7 +74,7 @@
   "create_page_dropdown": {
   "create_page_dropdown": {
     "new_page": "新页面",
     "new_page": "新页面",
     "todays": {
     "todays": {
-      "desc": "Create today's ...",
+      "desc": "Create today's memo",
       "memo": "memo"
       "memo": "memo"
     },
     },
     "template": {
     "template": {

+ 7 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -248,6 +248,13 @@
       "description": "您可以设置当屏幕宽度较大时,侧边栏是否始终打开。 如果屏幕宽度较小,侧边栏将始终关闭。"
       "description": "您可以设置当屏幕宽度较大时,侧边栏是否始终打开。 如果屏幕宽度较小,侧边栏将始终关闭。"
     }
     }
   },
   },
+  "color_mode_settings": {
+    "light": "灯光",
+    "dark": "暗处",
+    "system": "系统",
+    "settings": "色彩模式设置",
+    "description": "选择是以浅色模式、深色模式还是系统特定的显示方式显示。<br>只能切换支持的主题。"
+  },
   "editor_settings": {
   "editor_settings": {
     "editor_settings": "编辑器设置"
     "editor_settings": "编辑器设置"
   },
   },

+ 10 - 0
apps/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -39,6 +39,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isShowRestrictedByOwner: false,
       isShowRestrictedByOwner: false,
       isShowRestrictedByGroup: false,
       isShowRestrictedByGroup: false,
       isUsersHomepageDeletionEnabled: false,
       isUsersHomepageDeletionEnabled: false,
+      isForceDeleteUserHomepageOnUserDeletion: false,
       isLocalEnabled: false,
       isLocalEnabled: false,
       isLdapEnabled: false,
       isLdapEnabled: false,
       isSamlEnabled: false,
       isSamlEnabled: false,
@@ -75,6 +76,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
       isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
+      isForceDeleteUserHomepageOnUserDeletion: generalSetting.isForceDeleteUserHomepageOnUserDeletion,
       sessionMaxAge: generalSetting.sessionMaxAge,
       sessionMaxAge: generalSetting.sessionMaxAge,
       wikiMode: generalSetting.wikiMode,
       wikiMode: generalSetting.wikiMode,
       disableLinkSharing: shareLinkSetting.disableLinkSharing,
       disableLinkSharing: shareLinkSetting.disableLinkSharing,
@@ -202,6 +204,13 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ isUsersHomepageDeletionEnabled: !this.state.isUsersHomepageDeletionEnabled });
     this.setState({ isUsersHomepageDeletionEnabled: !this.state.isUsersHomepageDeletionEnabled });
   }
   }
 
 
+  /**
+   * Switch isForceDeleteUserHomepageOnUserDeletion
+   */
+  switchIsForceDeleteUserHomepageOnUserDeletion() {
+    this.setState({ isForceDeleteUserHomepageOnUserDeletion: !this.state.isForceDeleteUserHomepageOnUserDeletion });
+  }
+
   /**
   /**
    * Update restrictGuestMode
    * Update restrictGuestMode
    * @memberOf AdminGeneralSecuritySContainer
    * @memberOf AdminGeneralSecuritySContainer
@@ -219,6 +228,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
       isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,
       isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,
+      isForceDeleteUserHomepageOnUserDeletion: this.state.isForceDeleteUserHomepageOnUserDeletion,
     };
     };
 
 
     requestParams = await removeNullPropertyFromObject(requestParams);
     requestParams = await removeNullPropertyFromObject(requestParams);

+ 14 - 1
apps/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -468,8 +468,21 @@ class SecuritySetting extends React.Component {
                 {t('security_settings.user_homepage_deletion.enable_user_homepage_deletion')}
                 {t('security_settings.user_homepage_deletion.enable_user_homepage_deletion')}
               </label>
               </label>
             </div>
             </div>
+            <div className="custom-control custom-switch custom-checkbox-success mt-2">
+              <input
+                type="checkbox"
+                className="form-check-input"
+                id="is-force-delete-user-homepage-on-user-deletion"
+                checked={adminGeneralSecurityContainer.state.isForceDeleteUserHomepageOnUserDeletion}
+                onChange={() => { adminGeneralSecurityContainer.switchIsForceDeleteUserHomepageOnUserDeletion() }}
+                disabled={!adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled}
+              />
+              <label className="form-check-label" htmlFor="is-force-delete-user-homepage-on-user-deletion">
+                {t('security_settings.user_homepage_deletion.enable_force_delete_user_homepage_on_user_deletion')}
+              </label>
+            </div>
             <p
             <p
-              className="form-text text-muted small"
+              className="form-text text-muted small mt-2"
               dangerouslySetInnerHTML={{ __html: t('security_settings.user_homepage_deletion.desc') }}
               dangerouslySetInnerHTML={{ __html: t('security_settings.user_homepage_deletion.desc') }}
             />
             />
           </div>
           </div>

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

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

+ 15 - 0
apps/app/src/components/Me/ColorModeSettings.module.scss

@@ -0,0 +1,15 @@
+@use '@growi/core/scss/bootstrap/init' as *;
+
+.color-settings :global {
+  .btn {
+    font-weight: bold;
+    color: var(--color-global);
+    background-color: transparent;
+    border-width: 3px;
+  }
+
+  .btn-outline-secondary {
+    border-color: $gray-400;
+  }
+}
+

+ 62 - 0
apps/app/src/components/Me/ColorModeSettings.tsx

@@ -0,0 +1,62 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { Themes, useNextThemes } from '~/stores/use-next-themes';
+
+import styles from './ColorModeSettings.module.scss';
+
+export const ColorModeSettings = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { setTheme, theme } = useNextThemes();
+
+  const isActive = useCallback((targetTheme: Themes) => {
+    return targetTheme === theme;
+  }, [theme]);
+
+  return (
+    <div className={`color-settings ${styles['color-settings']}`}>
+      <h2 className="border-bottom mb-4">{t('color_mode_settings.settings')}</h2>
+
+      <div className="offset-md-3">
+        <div className="d-flex">
+          <button
+            type="button"
+            onClick={() => { setTheme(Themes.LIGHT) }}
+            // eslint-disable-next-line max-len
+            className={`btn py-2 px-4 me-4 d-flex align-items-center justify-content-center ${isActive(Themes.LIGHT) ? 'btn-outline-primary' : 'btn-outline-secondary'}`}
+          >
+            <span className="material-symbols-outlined fs-5 me-1">light_mode</span>
+            <span>{t('color_mode_settings.light')}</span>
+          </button>
+
+          <button
+            type="button"
+            onClick={() => { setTheme(Themes.DARK) }}
+            // eslint-disable-next-line max-len
+            className={`btn py-2 px-4 me-4 d-flex align-items-center justify-content-center ${isActive(Themes.DARK) ? 'btn-outline-primary' : 'btn-outline-secondary'}`}
+          >
+            <span className="material-symbols-outlined fs-5 me-1">dark_mode</span>
+            <span>{t('color_mode_settings.dark')}</span>
+          </button>
+
+          <button
+            type="button"
+            onClick={() => { setTheme(Themes.SYSTEM) }}
+            // eslint-disable-next-line max-len
+            className={`btn py-2 px-4 d-flex align-items-center justify-content-center ${isActive(Themes.SYSTEM) ? 'btn-outline-primary' : 'btn-outline-secondary'}`}
+          >
+            <span className="material-symbols-outlined fs-5 me-1">devices</span>
+            <span>{t('color_mode_settings.system')}</span>
+          </button>
+        </div>
+
+        <div className="mt-3 text-muted">
+          {/* eslint-disable-next-line react/no-danger */}
+          <span dangerouslySetInnerHTML={{ __html: t('color_mode_settings.description') }} />
+        </div>
+      </div>
+    </div>
+  );
+};

+ 6 - 1
apps/app/src/components/Me/OtherSettings.tsx

@@ -1,3 +1,4 @@
+import { ColorModeSettings } from './ColorModeSettings';
 import { QuestionnaireSettings } from './QuestionnaireSettings';
 import { QuestionnaireSettings } from './QuestionnaireSettings';
 import { UISettings } from './UISettings';
 import { UISettings } from './UISettings';
 
 
@@ -6,12 +7,16 @@ const OtherSettings = (): JSX.Element => {
   return (
   return (
     <>
     <>
       <div className="mt-4">
       <div className="mt-4">
-        <QuestionnaireSettings />
+        <ColorModeSettings />
       </div>
       </div>
 
 
       <div className="mt-4">
       <div className="mt-4">
         <UISettings />
         <UISettings />
       </div>
       </div>
+
+      <div className="mt-4">
+        <QuestionnaireSettings />
+      </div>
     </>
     </>
   );
   );
 };
 };

+ 30 - 62
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect, useState, useCallback } from 'react';
 import React, { useEffect, useState, useCallback } from 'react';
 
 
-import { PageGrant, GroupType } from '@growi/core';
+import { PageGrant } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -9,7 +9,7 @@ import {
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { IPageGrantData } from '~/interfaces/page';
 import { IPageGrantData } from '~/interfaces/page';
-import { ApplicableGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import { PopulatedGrantedGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
 
 
@@ -31,7 +31,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
   const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
 
 
   const [isGroupSelectModalShown, setIsGroupSelectModalShown] = useState(false);
   const [isGroupSelectModalShown, setIsGroupSelectModalShown] = useState(false);
-  const [selectedGroups, setSelectedGroups] = useState<ApplicableGroup[]>([]);
+  const [selectedGroup, setSelectedGroup] = useState<PopulatedGrantedGroup | undefined>(undefined);
 
 
   // Alert message state
   // Alert message state
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
@@ -42,23 +42,14 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   useEffect(() => {
   useEffect(() => {
     if (isOpen) {
     if (isOpen) {
       setSelectedGrant(PageGrant.GRANT_RESTRICTED);
       setSelectedGrant(PageGrant.GRANT_RESTRICTED);
-      setSelectedGroups([]);
+      setSelectedGroup(undefined);
       setShowModalAlert(false);
       setShowModalAlert(false);
     }
     }
   }, [isOpen]);
   }, [isOpen]);
 
 
-  const groupListItemClickHandler = (group: ApplicableGroup) => {
-    if (selectedGroups.find(g => g.item._id === group.item._id) != null) {
-      setSelectedGroups(selectedGroups.filter(g => g.item._id !== group.item._id));
-    }
-    else {
-      setSelectedGroups([...selectedGroups, group]);
-    }
-  };
-
   const submit = async() => {
   const submit = async() => {
     // Validate input values
     // Validate input values
-    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroups.length === 0) {
+    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroup == null) {
       setShowModalAlert(true);
       setShowModalAlert(true);
       return;
       return;
     }
     }
@@ -68,9 +59,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     try {
     try {
       await apiv3Put(`/page/${pageId}/grant`, {
       await apiv3Put(`/page/${pageId}/grant`, {
         grant: selectedGrant,
         grant: selectedGrant,
-        grantedGroups: selectedGroups.length !== 0 ? selectedGroups.map((g) => {
-          return { item: g.item._id, type: g.type };
-        }) : null,
+        grantedGroups: selectedGroup?.item._id != null ? [{ item: selectedGroup?.item._id, type: selectedGroup.type }] : null,
       });
       });
 
 
       toastSuccess(t('Successfully updated'));
       toastSuccess(t('Successfully updated'));
@@ -102,7 +91,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
       if (grantData.grantedGroups == null || grantData.grantedGroups.length === 0) {
       if (grantData.grantedGroups == null || grantData.grantedGroups.length === 0) {
         return t('fix_page_grant.modal.grant_label.isForbidden');
         return t('fix_page_grant.modal.grant_label.isForbidden');
       }
       }
-      return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantData.grantedGroups.map(g => g.name).join(', ')})`;
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroups[0].name})`;
     }
     }
 
 
     throw Error('cannot get grant label'); // this error can't be throwed
     throw Error('cannot get grant label'); // this error can't be throwed
@@ -191,17 +180,31 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
                   <button
                   <button
                     type="button"
                     type="button"
                     className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
                     className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
+                    data-toggle="dropdown"
                     disabled={selectedGrant !== PageGrant.GRANT_USER_GROUP} // disable when its radio input is not selected
                     disabled={selectedGrant !== PageGrant.GRANT_USER_GROUP} // disable when its radio input is not selected
-                    onClick={() => setIsGroupSelectModalShown(true)}
                   >
                   >
                     <span className="float-start ms-2">
                     <span className="float-start ms-2">
                       {
                       {
-                        selectedGroups.length === 0
+                        selectedGroup == null
                           ? t('fix_page_grant.modal.select_group_default_text')
                           ? t('fix_page_grant.modal.select_group_default_text')
-                          : selectedGroups.map(g => g.item.name).join(', ')
+                          : selectedGroup.item.name
                       }
                       }
                     </span>
                     </span>
                   </button>
                   </button>
+                  <div className="dropdown-menu">
+                    {
+                      applicableGroups != null && applicableGroups.map(g => (
+                        <button
+                          key={g.item._id}
+                          className="dropdown-item"
+                          type="button"
+                          onClick={() => setSelectedGroup(g)}
+                        >
+                          {g.item.name}
+                        </button>
+                      ))
+                    }
+                  </div>
                 </div>
                 </div>
               </div>
               </div>
               {
               {
@@ -224,47 +227,12 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   };
   };
 
 
   return (
   return (
-    <>
-      <Modal size="lg" isOpen={isOpen} toggle={close}>
-        <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
-          { t('fix_page_grant.modal.title') }
-        </ModalHeader>
-        {renderModalBodyAndFooter()}
-      </Modal>
-      {applicableGroups != null && (
-        <Modal
-          isOpen={isGroupSelectModalShown}
-          toggle={() => setIsGroupSelectModalShown(false)}
-        >
-          <ModalHeader tag="h4" toggle={() => setIsGroupSelectModalShown(false)} className="bg-purple text-light">
-            {t('user_group.select_group')}
-          </ModalHeader>
-          <ModalBody>
-            <>
-              { applicableGroups.map((group) => {
-                const groupIsGranted = selectedGroups?.find(g => g.item._id === group.item._id) != null;
-                const activeClass = groupIsGranted ? 'active' : '';
-
-                return (
-                  <button
-                    className={`btn btn-outline-primary w-100 d-flex justify-content-start mb-3 align-items-center p-3 ${activeClass}`}
-                    type="button"
-                    key={group.item._id}
-                    onClick={() => groupListItemClickHandler(group)}
-                  >
-                    <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
-                    <h5 className="d-inline-block ml-3">{group.item.name}</h5>
-                    {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
-                    {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
-                  </button>
-                );
-              }) }
-              <button type="button" className="btn btn-primary mt-2 float-right" onClick={() => setIsGroupSelectModalShown(false)}>{t('Done')}</button>
-            </>
-          </ModalBody>
-        </Modal>
-      )}
-    </>
+    <Modal size="lg" isOpen={isOpen} toggle={close}>
+      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+        { t('fix_page_grant.modal.title') }
+      </ModalHeader>
+      {renderModalBodyAndFooter()}
+    </Modal>
   );
   );
 };
 };
 
 

+ 1 - 5
apps/app/src/components/PageAlert/PageGrantAlert.tsx

@@ -14,10 +14,6 @@ export const PageGrantAlert = (): JSX.Element => {
     return <></>;
     return <></>;
   }
   }
 
 
-  const populatedGrantedGroups = () => {
-    return pageData.grantedGroups.filter(group => isPopulated(group.item));
-  };
-
   const renderAlertContent = () => {
   const renderAlertContent = () => {
     const getGrantLabel = () => {
     const getGrantLabel = () => {
       if (pageData.grant === 2) {
       if (pageData.grant === 2) {
@@ -39,7 +35,7 @@ export const PageGrantAlert = (): JSX.Element => {
           <>
           <>
             <i className="icon-fw icon-organization"></i>
             <i className="icon-fw icon-organization"></i>
             <strong>{
             <strong>{
-              populatedGrantedGroups().map(g => g.item.name).join(', ')
+              isPopulated(pageData.grantedGroups[0].item) && pageData.grantedGroups[0].item.name
             }
             }
             </strong>
             </strong>
           </>
           </>

+ 6 - 21
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -18,7 +18,7 @@ import { throttle, debounce } from 'throttle-debounce';
 
 
 import { useShouldExpandContent } from '~/client/services/layout';
 import { useShouldExpandContent } from '~/client/services/layout';
 import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
 import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
-import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
+import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { SocketEventName } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
@@ -313,10 +313,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const uploadHandler = useCallback((files: File[]) => {
   const uploadHandler = useCallback((files: File[]) => {
     files.forEach(async(file) => {
     files.forEach(async(file) => {
       try {
       try {
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        const resLimit: any = await apiGet('/attachments.limit', {
-          fileSize: file.size,
-        });
+        const { data: resLimit } = await apiv3Get('/attachment/limit', { fileSize: file.size });
 
 
         if (!resLimit.isUploadable) {
         if (!resLimit.isUploadable) {
           throw new Error(resLimit.errorMessage);
           throw new Error(resLimit.errorMessage);
@@ -324,17 +321,12 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
 
         const formData = new FormData();
         const formData = new FormData();
         formData.append('file', file);
         formData.append('file', file);
-        if (currentPagePath != null) {
-          formData.append('path', currentPagePath);
-        }
         if (pageId != null) {
         if (pageId != null) {
           formData.append('page_id', pageId);
           formData.append('page_id', pageId);
         }
         }
-        if (pageId == null) {
-          formData.append('page_body', codeMirrorEditor?.getDoc() ?? '');
-        }
 
 
-        const resAdd: any = await apiPostForm('/attachments.add', formData);
+        const { data: resAdd } = await apiv3PostForm('/attachment', formData);
+
         const attachment = resAdd.attachment;
         const attachment = resAdd.attachment;
         const fileName = attachment.originalName;
         const fileName = attachment.originalName;
 
 
@@ -344,23 +336,16 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
           // modify to "![fileName](url)" syntax
           // modify to "![fileName](url)" syntax
           insertText = `!${insertText}`;
           insertText = `!${insertText}`;
         }
         }
-        // TODO: implement
-        // refs: https://redmine.weseek.co.jp/issues/126528
-        // editorRef.current.insertText(insertText);
+
         codeMirrorEditor?.insertText(insertText);
         codeMirrorEditor?.insertText(insertText);
       }
       }
       catch (e) {
       catch (e) {
         logger.error('failed to upload', e);
         logger.error('failed to upload', e);
         toastError(e);
         toastError(e);
       }
       }
-      finally {
-        // TODO: implement
-        // refs: https://redmine.weseek.co.jp/issues/126528
-        // editorRef.current.terminateUploadingState();
-      }
     });
     });
 
 
-  }, [codeMirrorEditor, currentPagePath, pageId]);
+  }, [codeMirrorEditor, pageId]);
 
 
   const acceptedFileType = useMemo(() => {
   const acceptedFileType = useMemo(() => {
     if (!isUploadEnabled) {
     if (!isUploadEnabled) {

+ 12 - 34
apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -86,17 +86,12 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
 
   const groupListItemClickHandler = useCallback((grantGroup: IGrantedGroup) => {
   const groupListItemClickHandler = useCallback((grantGroup: IGrantedGroup) => {
     if (onUpdateGrant != null && isPopulated(grantGroup.item)) {
     if (onUpdateGrant != null && isPopulated(grantGroup.item)) {
-      let grantedGroupsCopy = grantedGroups != null ? [...grantedGroups] : [];
-      const grantGroupInfo = { id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type };
-      if (grantedGroupsCopy.find(group => group.id === grantGroupInfo.id) == null) {
-        grantedGroupsCopy.push(grantGroupInfo);
-      }
-      else {
-        grantedGroupsCopy = grantedGroupsCopy.filter(group => group.id !== grantGroupInfo.id);
-      }
-      onUpdateGrant({ grant: 5, grantedGroups: grantedGroupsCopy });
+      onUpdateGrant({ grant: 5, grantedGroups: [{ id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type }] });
     }
     }
-  }, [onUpdateGrant, grantedGroups]);
+
+    // hide modal
+    setIsSelectGroupModalShown(false);
+  }, [onUpdateGrant]);
 
 
   /**
   /**
    * Render grant selector DOM.
    * Render grant selector DOM.
@@ -132,15 +127,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
       const labelElm = (
       const labelElm = (
         <span>
         <span>
           <i className="icon icon-fw icon-organization"></i>
           <i className="icon icon-fw icon-organization"></i>
-          <span className="label">
-            {grantedGroups.length > 1
-              ? (
-                <span>
-                  {`${grantedGroups[0].name}... `}
-                  <span className="badge badge-purple">+{grantedGroups.length - 1}</span>
-                </span>
-              ) : grantedGroups[0].name}
-          </span>
+          <span className="label">{grantedGroups[0].name}</span>
         </span>
         </span>
       );
       );
 
 
@@ -193,30 +180,20 @@ export const GrantSelector = (props: Props): JSX.Element => {
     }
     }
 
 
     return (
     return (
-      <>
+      <div className="list-group">
         { myUserGroups.map((group) => {
         { myUserGroups.map((group) => {
-          const groupIsGranted = grantedGroups?.find(g => g.id === group.item._id) != null;
-          const activeClass = groupIsGranted ? 'active' : '';
-
           return (
           return (
-            <button
-              className={`btn btn-outline-primary w-100 d-flex justify-content-start mb-3 align-items-center p-3 ${activeClass}`}
-              type="button"
-              key={group.item._id}
-              onClick={() => groupListItemClickHandler(group)}
-            >
-              <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
-              <h5 className="d-inline-block ml-3">{group.item.name}</h5>
+            <button key={group.item._id} type="button" className="list-group-item list-group-item-action" onClick={() => groupListItemClickHandler(group)}>
+              <h5 className="d-inline-block">{group.item.name}</h5>
               {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
               {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
             </button>
             </button>
           );
           );
         }) }
         }) }
-        <button type="button" className="btn btn-primary mt-2 float-right" onClick={() => setIsSelectGroupModalShown(false)}>{t('Done')}</button>
-      </>
+      </div>
     );
     );
 
 
-  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t, grantedGroups]);
+  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t]);
 
 
   return (
   return (
     <>
     <>
@@ -225,6 +202,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
       {/* render modal */}
       {/* render modal */}
       { !disabled && currentUser != null && (
       { !disabled && currentUser != null && (
         <Modal
         <Modal
+          className="select-grant-group"
           isOpen={isSelectGroupModalShown}
           isOpen={isSelectGroupModalShown}
           toggle={() => setIsSelectGroupModalShown(false)}
           toggle={() => setIsSelectGroupModalShown(false)}
         >
         >

+ 4 - 1
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -2,6 +2,7 @@ import React, { useState, useCallback } from 'react';
 
 
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
 
 
 import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
 import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
@@ -15,6 +16,8 @@ import { DropendToggle } from './DropendToggle';
 import { useOnNewButtonClicked, useOnTodaysButtonClicked } from './hooks';
 import { useOnNewButtonClicked, useOnTodaysButtonClicked } from './hooks';
 
 
 export const PageCreateButton = React.memo((): JSX.Element => {
 export const PageCreateButton = React.memo((): JSX.Element => {
+  const { t } = useTranslation('commons');
+
   const { data: currentPage, isLoading } = useSWRxCurrentPage();
   const { data: currentPage, isLoading } = useSWRxCurrentPage();
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
 
 
@@ -22,7 +25,7 @@ export const PageCreateButton = React.memo((): JSX.Element => {
 
 
   const now = format(new Date(), 'yyyy/MM/dd');
   const now = format(new Date(), 'yyyy/MM/dd');
   const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
   const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
-  const todaysPath = `${userHomepagePath}/memo/${now}`;
+  const todaysPath = `${userHomepagePath}/${t('create_page_dropdown.todays.memo')}/${now}`;
 
 
   const { onClickHandler: onClickNewButton, isPageCreating: isNewPageCreating } = useOnNewButtonClicked(isLoading, currentPage);
   const { onClickHandler: onClickNewButton, isPageCreating: isNewPageCreating } = useOnNewButtonClicked(isLoading, currentPage);
   const { onClickHandler: onClickTodaysButton, isPageCreating: isTodaysPageCreating } = useOnTodaysButtonClicked(todaysPath, currentUser);
   const { onClickHandler: onClickTodaysButton, isPageCreating: isTodaysPageCreating } = useOnTodaysButtonClicked(todaysPath, currentUser);

+ 1 - 2
apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -23,7 +23,7 @@ import { Ellipsis } from './Ellipsis';
 const logger = loggerFactory('growi:cli:Item');
 const logger = loggerFactory('growi:cli:Item');
 
 
 type PageTreeItemPropsOptional = 'itemRef' | 'itemClass' | 'mainClassName';
 type PageTreeItemPropsOptional = 'itemRef' | 'itemClass' | 'mainClassName';
-type PageTreeItemProps = Omit<SimpleItemProps, PageTreeItemPropsOptional> & {key};
+type PageTreeItemProps = Omit<SimpleItemProps, PageTreeItemPropsOptional>;
 
 
 export const PageTreeItem: FC<PageTreeItemProps> = (props) => {
 export const PageTreeItem: FC<PageTreeItemProps> = (props) => {
   const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
   const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
@@ -158,7 +158,6 @@ export const PageTreeItem: FC<PageTreeItemProps> = (props) => {
 
 
   return (
   return (
     <SimpleItem
     <SimpleItem
-      key={props.key}
       targetPathOrId={props.targetPathOrId}
       targetPathOrId={props.targetPathOrId}
       itemNode={props.itemNode}
       itemNode={props.itemNode}
       isOpen
       isOpen

+ 6 - 4
apps/app/src/components/TreeItem/SimpleItem.tsx

@@ -252,13 +252,15 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
             </button>
             </button>
           )}
           )}
         </div>
         </div>
-        {SimpleItemContent.map(ItemContent => (
-          <ItemContent {...SimpleItemContentProps} />
+        {SimpleItemContent.map((ItemContent, index) => (
+          // eslint-disable-next-line react/no-array-index-key
+          <ItemContent key={index} {...SimpleItemContentProps} />
         ))}
         ))}
       </li>
       </li>
 
 
-      {CustomNextComponents?.map(UnderItemContent => (
-        <UnderItemContent {...SimpleItemContentProps} />
+      {CustomNextComponents?.map((UnderItemContent, index) => (
+        // eslint-disable-next-line react/no-array-index-key
+        <UnderItemContent key={index} {...SimpleItemContentProps} />
       ))}
       ))}
 
 
       {
       {

+ 1 - 0
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx

@@ -149,6 +149,7 @@ export const SyncExecution = ({
       </form>
       </form>
 
 
       <Modal
       <Modal
+        className="select-grant-group"
         isOpen={isAlertModalOpen}
         isOpen={isAlertModalOpen}
         toggle={() => setIsAlertModalOpen(false)}
         toggle={() => setIsAlertModalOpen(false)}
       >
       >

+ 6 - 6
apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx

@@ -12,7 +12,7 @@ type Props = {
   id: string,
   id: string,
   name: string,
   name: string,
   url: string,
   url: string,
-  isEnalbed: boolean,
+  isEnabled: boolean,
   desc?: string,
   desc?: string,
   onDelete: () => void,
   onDelete: () => void,
 }
 }
@@ -20,27 +20,27 @@ type Props = {
 export const PluginCard = (props: Props): JSX.Element => {
 export const PluginCard = (props: Props): JSX.Element => {
 
 
   const {
   const {
-    id, name, url, isEnalbed, desc,
+    id, name, url, isEnabled, desc,
   } = props;
   } = props;
 
 
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const PluginCardButton = (): JSX.Element => {
   const PluginCardButton = (): JSX.Element => {
-    const [isEnabled, setState] = useState<boolean>(isEnalbed);
+    const [_isEnabled, setIsEnabled] = useState<boolean>(isEnabled);
 
 
     const onChangeHandler = async() => {
     const onChangeHandler = async() => {
       try {
       try {
-        if (isEnabled) {
+        if (_isEnabled) {
           const reqUrl = `/plugins/${id}/deactivate`;
           const reqUrl = `/plugins/${id}/deactivate`;
           const res = await apiv3Put(reqUrl);
           const res = await apiv3Put(reqUrl);
-          setState(!isEnabled);
+          setIsEnabled(!_isEnabled);
           const pluginName = res.data.pluginName;
           const pluginName = res.data.pluginName;
           toastSuccess(t('toaster.deactivate_plugin_success', { pluginName }));
           toastSuccess(t('toaster.deactivate_plugin_success', { pluginName }));
         }
         }
         else {
         else {
           const reqUrl = `/plugins/${id}/activate`;
           const reqUrl = `/plugins/${id}/activate`;
           const res = await apiv3Put(reqUrl);
           const res = await apiv3Put(reqUrl);
-          setState(!isEnabled);
+          setIsEnabled(!_isEnabled);
           const pluginName = res.data.pluginName;
           const pluginName = res.data.pluginName;
           toastSuccess(t('toaster.activate_plugin_success', { pluginName }));
           toastSuccess(t('toaster.activate_plugin_success', { pluginName }));
         }
         }

+ 1 - 1
apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx

@@ -54,7 +54,7 @@ export const PluginsExtensionPageContents = (): JSX.Element => {
                     id={plugin._id}
                     id={plugin._id}
                     name={plugin.meta.name}
                     name={plugin.meta.name}
                     url={plugin.origin.url}
                     url={plugin.origin.url}
-                    isEnalbed={plugin.isEnabled}
+                    isEnabled={plugin.isEnabled}
                     desc={plugin.meta.desc}
                     desc={plugin.meta.desc}
                     onDelete={() => openPluginDeleteModal(plugin)}
                     onDelete={() => openPluginDeleteModal(plugin)}
                   />
                   />

+ 2 - 2
apps/app/src/interfaces/page-grant.ts

@@ -8,9 +8,9 @@ import { IPageGrantData } from './page';
 
 
 type UserGroupType = typeof GroupType.userGroup;
 type UserGroupType = typeof GroupType.userGroup;
 type ExternalUserGroupType = typeof GroupType.externalUserGroup;
 type ExternalUserGroupType = typeof GroupType.externalUserGroup;
-export type ApplicableGroup = {type: UserGroupType, item: UserGroupDocument } | {type: ExternalUserGroupType, item: ExternalUserGroupDocument }
+export type PopulatedGrantedGroup = {type: UserGroupType, item: UserGroupDocument } | {type: ExternalUserGroupType, item: ExternalUserGroupDocument }
 export type IDataApplicableGroup = {
 export type IDataApplicableGroup = {
-  applicableGroups?: ApplicableGroup[]
+  applicableGroups?: PopulatedGrantedGroup[]
 }
 }
 
 
 export type IDataApplicableGrant = null | IDataApplicableGroup;
 export type IDataApplicableGrant = null | IDataApplicableGroup;

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

@@ -22,7 +22,8 @@ import superjson from 'superjson';
 
 
 import { useEditorModeClassName } from '~/client/services/layout';
 import { useEditorModeClassName } from '~/client/services/layout';
 import { PageView } from '~/components/Page/PageView';
 import { PageView } from '~/components/Page/PageView';
-import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript'; import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { EditorConfig } from '~/interfaces/editor-settings';
 import type { EditorConfig } from '~/interfaces/editor-settings';
 import type { IPageGrantData } from '~/interfaces/page';
 import type { IPageGrantData } from '~/interfaces/page';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
@@ -396,24 +397,6 @@ class MultiplePagesHitsError extends ExtensibleCustomError {
 
 
 }
 }
 
 
-// apply parent page grant fot creating page
-async function applyGrantToPage(props: Props, ancestor: any) {
-  await ancestor.populate('grantedGroups.item');
-  const grant = {
-    grant: ancestor.grant,
-  };
-  const grantedGroups = ancestor.grantedGroups ? {
-    grantedGroups: ancestor.grantedGroups.map((group) => {
-      return {
-        id: group.item._id,
-        name: group.item.name,
-        type: group.type,
-      };
-    }),
-  } : {};
-  props.grantData = Object.assign(grant, grantedGroups);
-}
-
 async function injectPageData(context: GetServerSidePropsContext, props: Props): Promise<void> {
 async function injectPageData(context: GetServerSidePropsContext, props: Props): Promise<void> {
   const { model: mongooseModel } = await import('mongoose');
   const { model: mongooseModel } = await import('mongoose');
 
 
@@ -474,10 +457,20 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
       props.templateBodyData = templateData.templateBody as string;
       props.templateBodyData = templateData.templateBody as string;
     }
     }
 
 
-    // apply parent page grant
+    // apply parent page grant, without groups that user isn't related to
     const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
     const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
     if (ancestor != null) {
     if (ancestor != null) {
-      await applyGrantToPage(props, ancestor);
+      const userRelatedGrantedGroups = await pageService.getUserRelatedGrantedGroups(ancestor, user);
+      props.grantData = {
+        grant: ancestor.grant,
+        grantedGroups: userRelatedGrantedGroups.map((group) => {
+          return {
+            id: group.item._id,
+            name: group.item.name,
+            type: group.type,
+          };
+        }),
+      };
     }
     }
   }
   }
 
 

+ 18 - 18
apps/app/src/server/events/user.ts

@@ -1,8 +1,10 @@
 import EventEmitter from 'events';
 import EventEmitter from 'events';
 
 
-import type { IUserHasId } from '@growi/core';
+import type { IPage, IUserHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
+import mongoose from 'mongoose';
 
 
+import type { PageModel } from '~/server/models/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:events:user');
 const logger = loggerFactory('growi:events:user');
@@ -18,31 +20,29 @@ class UserEvent extends EventEmitter {
   }
   }
 
 
   async onActivated(user: IUserHasId): Promise<void> {
   async onActivated(user: IUserHasId): Promise<void> {
-    if (this.crowi.pageService === null) {
-      logger.warn('crowi pageService is null');
-      return;
-    }
-
-    const Page = this.crowi.model('Page');
+    const Page = mongoose.model<IPage, PageModel>('Page');
     const userHomepagePath = pagePathUtils.userHomepagePath(user);
     const userHomepagePath = pagePathUtils.userHomepagePath(user);
 
 
-    let page = await Page.findByPath(userHomepagePath, true);
+    try {
+      let page = await Page.findByPath(userHomepagePath, true);
 
 
-    if (page != null && page.creator != null && page.creator.toString() !== user._id.toString()) {
-      await this.crowi.pageService.deleteCompletelyUserHomeBySystem(userHomepagePath);
-      page = null;
-    }
+      // TODO: Make it more type safe
+      // Since the type of page.creator is 'any', we resort to the following comparison,
+      // checking if page.creator.toString() is not equal to user._id.toString(). Our code covers null, string, or object types.
+      if (page != null && page.creator != null && page.creator.toString() !== user._id.toString()) {
+        await this.crowi.pageService.deleteCompletelyUserHomeBySystem(userHomepagePath);
+        page = null;
+      }
 
 
-    if (page == null) {
-      const body = `# ${user.username}\nThis is ${user.username}'s page`;
+      if (page == null) {
+        const body = `# ${user.username}\nThis is ${user.username}'s page`;
 
 
-      try {
         await this.crowi.pageService.create(userHomepagePath, body, user, {});
         await this.crowi.pageService.create(userHomepagePath, body, user, {});
         logger.debug('User page created', page);
         logger.debug('User page created', page);
       }
       }
-      catch (err) {
-        logger.error('Failed to create user page', err);
-      }
+    }
+    catch (err) {
+      logger.error('Failed to create user page', err);
     }
     }
   }
   }
 
 

+ 2 - 1
apps/app/src/server/models/config.ts

@@ -71,7 +71,8 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'security:pageRecursiveDeletionAuthority' : undefined,
   'security:pageRecursiveDeletionAuthority' : undefined,
   'security:pageRecursiveCompleteDeletionAuthority' : undefined,
   'security:pageRecursiveCompleteDeletionAuthority' : undefined,
   'security:disableLinkSharing' : false,
   'security:disableLinkSharing' : false,
-  'security:isUsersHomepageDeletionEnabled': false,
+  'security:user-homepage-deletion:isEnabled': false,
+  'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': false,
 
 
   'security:passport-local:isEnabled' : true,
   'security:passport-local:isEnabled' : true,
   'security:passport-ldap:isEnabled' : false,
   'security:passport-ldap:isEnabled' : false,

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

@@ -18,13 +18,16 @@ import mongoose, {
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 import uniqueValidator from 'mongoose-unique-validator';
 
 
+import { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
+import { UserGroupDocument } from './user-group';
 import UserGroupRelation from './user-group-relation';
 import UserGroupRelation from './user-group-relation';
 
 
 const logger = loggerFactory('growi:models:page');
 const logger = loggerFactory('growi:models:page');
@@ -71,6 +74,7 @@ export interface PageModel extends Model<PageDocument> {
   generateGrantCondition(
   generateGrantCondition(
     user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
     user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
   ): { $or: any[] }
+  removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
 
 
   PageQueryBuilder: typeof PageQueryBuilder
   PageQueryBuilder: typeof PageQueryBuilder
 
 

+ 246 - 1
apps/app/src/server/routes/apiv3/attachment.js

@@ -1,17 +1,28 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
+import multer from 'multer';
+import autoReap from 'multer-autoreap';
 
 
+import { SupportedAction } from '~/interfaces/activity';
+import { AttachmentType } from '~/server/interfaces/attachment';
 import { Attachment } from '~/server/models';
 import { Attachment } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify-shared-page-attachment';
 import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify-shared-page-attachment';
+import { excludeReadOnlyUser } from '../../middlewares/exclude-read-only-user';
+
 
 
 const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
 const express = require('express');
 const express = require('express');
 
 
 const router = express.Router();
 const router = express.Router();
-const { query, param } = require('express-validator');
+const {
+  query, param, body,
+} = require('express-validator');
 
 
+const { serializePageSecurely } = require('../../models/serializers/page-serializer');
+const { serializeRevisionSecurely } = require('../../models/serializers/revision-serializer');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 
 /**
 /**
@@ -20,11 +31,75 @@ const { serializeUserSecurely } = require('../../models/serializers/user-seriali
  *    name: Attachment
  *    name: Attachment
  */
  */
 
 
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      Attachment:
+ *        description: Attachment
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: attachment ID
+ *            example: 5e0734e072560e001761fa67
+ *          __v:
+ *            type: number
+ *            description: attachment version
+ *            example: 0
+ *          fileFormat:
+ *            type: string
+ *            description: file format in MIME
+ *            example: text/plain
+ *          fileName:
+ *            type: string
+ *            description: file name
+ *            example: 601b7c59d43a042c0117e08dd37aad0aimage.txt
+ *          originalName:
+ *            type: string
+ *            description: original file name
+ *            example: file.txt
+ *          creator:
+ *            $ref: '#/components/schemas/User'
+ *          page:
+ *            type: string
+ *            description: page ID attached at
+ *            example: 5e07345972560e001761fa63
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ *          fileSize:
+ *            type: number
+ *            description: file size
+ *            example: 3494332
+ *          url:
+ *            type: string
+ *            description: attachment URL
+ *            example: http://localhost/files/5e0734e072560e001761fa67
+ *          filePathProxied:
+ *            type: string
+ *            description: file path proxied
+ *            example: "/attachment/5e0734e072560e001761fa67"
+ *          downloadPathProxied:
+ *            type: string
+ *            description: download path proxied
+ *            example: "/download/5e0734e072560e001761fa67"
+ */
+
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const Page = crowi.model('Page');
   const Page = crowi.model('Page');
   const User = crowi.model('User');
   const User = crowi.model('User');
+  const { attachmentService } = crowi;
+  const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
 
   const validator = {
   const validator = {
     retrieveAttachment: [
     retrieveAttachment: [
@@ -35,6 +110,12 @@ module.exports = (crowi) => {
       query('pageNumber').optional().isInt().withMessage('pageNumber must be a number'),
       query('pageNumber').optional().isInt().withMessage('pageNumber must be a number'),
       query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
       query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
     ],
     ],
+    retrieveFileLimit: [
+      query('fileSize').isNumeric().exists({ checkNull: true }).withMessage('fileSize is required'),
+    ],
+    retrieveAddAttachment: [
+      body('page_id').isString().exists({ checkNull: true }).withMessage('page_id is required'),
+    ],
   };
   };
 
 
   /**
   /**
@@ -95,6 +176,170 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
+
+  /**
+   * @swagger
+   *
+   *    /attachment/limit:
+   *      get:
+   *        tags: [Attachment]
+   *        operationId: getAttachmentLimit
+   *        summary: /attachment/limit
+   *        description: Get available capacity of uploaded file with GridFS
+   *        parameters:
+   *          - in: query
+   *            name: fileSize
+   *            schema:
+   *              type: number
+   *              description: file size
+   *              example: 23175
+   *            required: true
+   *        responses:
+   *          200:
+   *            description: Succeeded to get available capacity of uploaded file with GridFS.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    isUploadable:
+   *                      type: boolean
+   *                      description: uploadable
+   *                      example: true
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
+  /**
+   * @api {get} /attachment/limit get available capacity of uploaded file with GridFS
+   * @apiName AddAttachment
+   * @apiGroup Attachment
+   */
+  router.get('/limit', accessTokenParser, loginRequiredStrictly, validator.retrieveFileLimit, apiV3FormValidator, async(req, res) => {
+    const { fileUploadService } = crowi;
+    const fileSize = Number(req.query.fileSize);
+    try {
+      return res.apiv3(await fileUploadService.checkLimit(fileSize));
+    }
+    catch (err) {
+      logger.error('File limit retrieval failed', err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /attachment:
+   *      post:
+   *        tags: [Attachment, CrowiCompatibles]
+   *        operationId: addAttachment
+   *        summary: /attachment
+   *        description: Add attachment to the page
+   *        requestBody:
+   *          content:
+   *            "multipart/form-data":
+   *              schema:
+   *                properties:
+   *                  page_id:
+   *                    nullable: true
+   *                    type: string
+   *                  path:
+   *                    nullable: true
+   *                    type: string
+   *                  file:
+   *                    type: string
+   *                    format: binary
+   *                    description: attachment data
+   *              encoding:
+   *                path:
+   *                  contentType: application/x-www-form-urlencoded
+   *            "*\/*":
+   *              schema:
+   *                properties:
+   *                  page_id:
+   *                    nullable: true
+   *                    type: string
+   *                  path:
+   *                    nullable: true
+   *                    type: string
+   *                  file:
+   *                    type: string
+   *                    format: binary
+   *                    description: attachment data
+   *              encoding:
+   *                path:
+   *                  contentType: application/x-www-form-urlencoded
+   *        responses:
+   *          200:
+   *            description: Succeeded to add attachment.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *                    attachment:
+   *                      $ref: '#/components/schemas/Attachment'
+   *                    url:
+   *                      $ref: '#/components/schemas/Attachment/properties/url'
+   *                    pageCreated:
+   *                      type: boolean
+   *                      description: whether the page was created
+   *                      example: false
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
+  /**
+   * @api {post} /attachment Add attachment to the page
+   * @apiName AddAttachment
+   * @apiGroup Attachment
+   *
+   * @apiParam {String} page_id
+   * @apiParam {String} path
+   * @apiParam {File} file
+   */
+  router.post('/', uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser,
+    validator.retrieveAddAttachment, apiV3FormValidator, addActivity,
+    async(req, res) => {
+
+      const pageId = req.body.page_id;
+
+      // check params
+      const file = req.file || null;
+      if (file == null) {
+        return res.apiv3Err('File error.');
+      }
+
+      try {
+        const page = await Page.findById(pageId);
+
+        // check the user is accessible
+        const isAccessible = await Page.isAccessiblePageByViewer(page.id, req.user);
+        if (!isAccessible) {
+          return res.apiv3Err(`Forbidden to access to the page '${page.id}'`);
+        }
+
+        const attachment = await attachmentService.createAttachment(file, req.user, pageId, AttachmentType.WIKI_PAGE);
+
+        const result = {
+          page: serializePageSecurely(page),
+          revision: serializeRevisionSecurely(page.revision),
+          attachment: attachment.toObject({ virtuals: true }),
+        };
+
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ATTACHMENT_ADD });
+
+        res.apiv3(result);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err.message);
+      }
+    });
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *

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

@@ -567,6 +567,11 @@ module.exports = (crowi) => {
     const { pageId } = req.params;
     const { pageId } = req.params;
     const { grant, grantedGroups } = req.body;
     const { grant, grantedGroups } = req.body;
 
 
+    // TODO: remove in https://redmine.weseek.co.jp/issues/136137
+    if (grantedGroups != null && grantedGroups.length > 1) {
+      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
+    }
+
     const Page = crowi.model('Page');
     const Page = crowi.model('Page');
 
 
     const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
     const page = await Page.findByIdAndViewer(pageId, req.user, null, false);

+ 13 - 9
apps/app/src/server/routes/apiv3/pages.js

@@ -310,6 +310,11 @@ module.exports = (crowi) => {
       body, grant, grantUserGroupIds, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
       body, grant, grantUserGroupIds, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
     } = req.body;
     } = req.body;
 
 
+    // TODO: remove in https://redmine.weseek.co.jp/issues/136136
+    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
+      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
+    }
+
     let { path } = req.body;
     let { path } = req.body;
 
 
     // check whether path starts slash
     // check whether path starts slash
@@ -817,6 +822,11 @@ module.exports = (crowi) => {
 
 
       const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
       const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
 
+      // TODO: remove in https://redmine.weseek.co.jp/issues/136139
+      if (page.grantedGroups != null && page.grantedGroups.length > 1) {
+        return res.apiv3Err('Cannot grant multiple groups to page at the moment');
+      }
+
       const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
       const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
       if (page == null || isEmptyAndNotRecursively) {
       if (page == null || isEmptyAndNotRecursively) {
         res.code = 'Page is not found';
         res.code = 'Page is not found';
@@ -935,18 +945,12 @@ module.exports = (crowi) => {
     }
     }
 
 
     let pagesCanBeDeleted;
     let pagesCanBeDeleted;
-    /*
-     * Delete Completely
-     */
     if (isCompletely) {
     if (isCompletely) {
-      pagesCanBeDeleted = crowi.pageService.filterPagesByCanDeleteCompletely(pagesToDelete, req.user, isRecursively);
+      pagesCanBeDeleted = await crowi.pageService.filterPagesByCanDeleteCompletely(pagesToDelete, req.user, isRecursively);
     }
     }
-    /*
-     * Trash
-     */
     else {
     else {
-      pagesCanBeDeleted = pagesToDelete.filter(p => p.isEmpty || p.isUpdatable(pageIdToRevisionIdMap[p._id].toString()));
-      pagesCanBeDeleted = crowi.pageService.filterPagesByCanDelete(pagesToDelete, req.user, isRecursively);
+      const filteredPages = pagesToDelete.filter(p => p.isEmpty || p.isUpdatable(pageIdToRevisionIdMap[p._id].toString()));
+      pagesCanBeDeleted = await crowi.pageService.filterPagesByCanDelete(filteredPages, req.user, isRecursively);
     }
     }
 
 
     if (pagesCanBeDeleted.length === 0) {
     if (pagesCanBeDeleted.length === 0) {

+ 12 - 3
apps/app/src/server/routes/apiv3/security-settings/index.js

@@ -31,6 +31,7 @@ const validator = {
     body('hideRestrictedByOwner').if(value => value != null).isBoolean(),
     body('hideRestrictedByOwner').if(value => value != null).isBoolean(),
     body('hideRestrictedByGroup').if(value => value != null).isBoolean(),
     body('hideRestrictedByGroup').if(value => value != null).isBoolean(),
     body('isUsersHomepageDeletionEnabled').if(value => value != null).isBoolean(),
     body('isUsersHomepageDeletionEnabled').if(value => value != null).isBoolean(),
+    body('isForceDeleteUserHomepageOnUserDeletion').if(value => value != null).isBoolean(),
   ],
   ],
   shareLinkSetting: [
   shareLinkSetting: [
     body('disableLinkSharing').if(value => value != null).isBoolean(),
     body('disableLinkSharing').if(value => value != null).isBoolean(),
@@ -358,7 +359,9 @@ module.exports = (crowi) => {
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
-        isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled'),
+        isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled'),
+        isForceDeleteUserHomepageOnUserDeletion:
+        await configManager.getConfig('crowi', 'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion'),
         wikiMode: await configManager.getConfig('crowi', 'security:wikiMode'),
         wikiMode: await configManager.getConfig('crowi', 'security:wikiMode'),
         sessionMaxAge: await configManager.getConfig('crowi', 'security:sessionMaxAge'),
         sessionMaxAge: await configManager.getConfig('crowi', 'security:sessionMaxAge'),
       },
       },
@@ -626,7 +629,11 @@ module.exports = (crowi) => {
       'security:pageRecursiveCompleteDeletionAuthority': req.body.pageRecursiveCompleteDeletionAuthority,
       'security:pageRecursiveCompleteDeletionAuthority': req.body.pageRecursiveCompleteDeletionAuthority,
       'security:list-policy:hideRestrictedByOwner': req.body.hideRestrictedByOwner,
       'security:list-policy:hideRestrictedByOwner': req.body.hideRestrictedByOwner,
       'security:list-policy:hideRestrictedByGroup': req.body.hideRestrictedByGroup,
       'security:list-policy:hideRestrictedByGroup': req.body.hideRestrictedByGroup,
-      'security:isUsersHomepageDeletionEnabled': req.body.isUsersHomepageDeletionEnabled,
+      'security:user-homepage-deletion:isEnabled': req.body.isUsersHomepageDeletionEnabled,
+      // Validate user-homepage-deletion config
+      'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': req.body.isUsersHomepageDeletionEnabled
+        ? req.body.isForceDeleteUserHomepageOnUserDeletion
+        : false,
     };
     };
 
 
     // Validate delete config
     // Validate delete config
@@ -655,7 +662,9 @@ module.exports = (crowi) => {
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
-        isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled'),
+        isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled'),
+        isForceDeleteUserHomepageOnUserDeletion:
+        await configManager.getConfig('crowi', 'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion'),
       };
       };
 
 
       const parameters = { action: SupportedAction.ACTION_ADMIN_SECURITY_SETTINGS_UPDATE };
       const parameters = { action: SupportedAction.ACTION_ADMIN_SECURITY_SETTINGS_UPDATE };

+ 5 - 10
apps/app/src/server/routes/apiv3/users.js

@@ -779,7 +779,7 @@ module.exports = (crowi) => {
    *        tags: [Users]
    *        tags: [Users]
    *        operationId: removeUser
    *        operationId: removeUser
    *        summary: /users/{id}/remove
    *        summary: /users/{id}/remove
-   *        description: Delete user and if isUsersHomepageDeletionEnabled delete user homepage and subpages
+   *        description: Delete user
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
    *            in: path
    *            in: path
@@ -789,7 +789,7 @@ module.exports = (crowi) => {
    *              type: string
    *              type: string
    *        responses:
    *        responses:
    *          200:
    *          200:
-   *            description: Deleting user success and if isUsersHomepageDeletionEnabled delete user homepage and subpages success
+   *            description: Deleting user success
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
@@ -797,16 +797,11 @@ module.exports = (crowi) => {
    *                    user:
    *                    user:
    *                      type: object
    *                      type: object
    *                      description: data of deleted user
    *                      description: data of deleted user
-   *                    userHomepagePath:
-   *                      type: string
-   *                      description: a user homepage path
-   *                    isUsersHomepageDeletionEnabled:
-   *                      type: boolean
-   *                      description: is users homepage deletion enabled
    */
    */
   router.delete('/:id/remove', loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity, async(req, res) => {
   router.delete('/:id/remove', loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity, async(req, res) => {
     const { id } = req.params;
     const { id } = req.params;
-    const isUsersHomepageDeletionEnabled = configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled');
+    const isUsersHomepageDeletionEnabled = configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled');
+    const isForceDeleteUserHomepageOnUserDeletion = configManager.getConfig('crowi', 'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion');
 
 
     try {
     try {
       const user = await User.findById(id);
       const user = await User.findById(id);
@@ -823,7 +818,7 @@ module.exports = (crowi) => {
 
 
       activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE });
       activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE });
 
 
-      if (isUsersHomepageDeletionEnabled) {
+      if (isUsersHomepageDeletionEnabled && isForceDeleteUserHomepageOnUserDeletion) {
         crowi.pageService.deleteCompletelyUserHomeBySystem(homepagePath);
         crowi.pageService.deleteCompletelyUserHomeBySystem(homepagePath);
       }
       }
 
 

+ 0 - 162
apps/app/src/server/routes/attachment/api.js

@@ -176,168 +176,6 @@ export const routesFactory = (crowi) => {
   //   return responseForAttachment(req, res, attachment, true);
   //   return responseForAttachment(req, res, attachment, true);
   // };
   // };
 
 
-  /**
-   * @swagger
-   *
-   *    /attachments.limit:
-   *      get:
-   *        tags: [Attachments]
-   *        operationId: getAttachmentsLimit
-   *        summary: /attachments.limit
-   *        description: Get available capacity of uploaded file with GridFS
-   *        parameters:
-   *          - in: query
-   *            name: fileSize
-   *            schema:
-   *              type: number
-   *              description: file size
-   *              example: 23175
-   *            required: true
-   *        responses:
-   *          200:
-   *            description: Succeeded to get available capacity of uploaded file with GridFS.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    isUploadable:
-   *                      type: boolean
-   *                      description: uploadable
-   *                      example: true
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /attachments.limit get available capacity of uploaded file with GridFS
-   * @apiName AddAttachments
-   * @apiGroup Attachment
-   */
-  api.limit = async function(req, res) {
-    const { fileUploadService } = crowi;
-    const fileSize = Number(req.query.fileSize);
-    return res.json(ApiResponse.success(await fileUploadService.checkLimit(fileSize)));
-  };
-
-  /**
-   * @swagger
-   *
-   *    /attachments.add:
-   *      post:
-   *        tags: [Attachments, CrowiCompatibles]
-   *        operationId: addAttachment
-   *        summary: /attachments.add
-   *        description: Add attachment to the page
-   *        requestBody:
-   *          content:
-   *            "multipart/form-data":
-   *              schema:
-   *                properties:
-   *                  page_id:
-   *                    nullable: true
-   *                    type: string
-   *                  path:
-   *                    nullable: true
-   *                    type: string
-   *                  file:
-   *                    type: string
-   *                    format: binary
-   *                    description: attachment data
-   *              encoding:
-   *                path:
-   *                  contentType: application/x-www-form-urlencoded
-   *            "*\/*":
-   *              schema:
-   *                properties:
-   *                  page_id:
-   *                    nullable: true
-   *                    type: string
-   *                  path:
-   *                    nullable: true
-   *                    type: string
-   *                  file:
-   *                    type: string
-   *                    format: binary
-   *                    description: attachment data
-   *              encoding:
-   *                path:
-   *                  contentType: application/x-www-form-urlencoded
-   *        responses:
-   *          200:
-   *            description: Succeeded to add attachment.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
-   *                    attachment:
-   *                      $ref: '#/components/schemas/Attachment'
-   *                    url:
-   *                      $ref: '#/components/schemas/Attachment/properties/url'
-   *                    pageCreated:
-   *                      type: boolean
-   *                      description: whether the page was created
-   *                      example: false
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {post} /attachments.add Add attachment to the page
-   * @apiName AddAttachments
-   * @apiGroup Attachment
-   *
-   * @apiParam {String} page_id
-   * @apiParam {File} file
-   */
-  api.add = async function(req, res) {
-    const pageId = req.body.page_id || null;
-    const pagePath = req.body.path || null;
-
-    // check params
-    if (pageId == null && pagePath == null) {
-      return res.json(ApiResponse.error('Either page_id or path is required.'));
-    }
-    if (req.file == null) {
-      return res.json(ApiResponse.error('File error.'));
-    }
-
-    const file = req.file;
-
-    try {
-      const page = await Page.findById(pageId);
-
-      // check the user is accessible
-      const isAccessible = await Page.isAccessiblePageByViewer(page.id, req.user);
-      if (!isAccessible) {
-        return res.json(ApiResponse.error(`Forbidden to access to the page '${page.id}'`));
-      }
-
-      const attachment = await attachmentService.createAttachment(file, req.user, pageId, AttachmentType.WIKI_PAGE);
-
-      const result = {
-        page: serializePageSecurely(page),
-        revision: serializeRevisionSecurely(page.revision),
-        attachment: attachment.toObject({ virtuals: true }),
-      };
-
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ATTACHMENT_ADD });
-
-      res.json(ApiResponse.success(result));
-    }
-    catch (err) {
-      logger.error(err);
-      return res.json(ApiResponse.error(err.message));
-    }
-  };
-
   /**
   /**
    * @swagger
    * @swagger
    *
    *

+ 0 - 2
apps/app/src/server/routes/index.js

@@ -139,11 +139,9 @@ module.exports = function(crowi, app) {
   apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.update);
   apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.update);
   apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.remove);
   apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.remove);
 
 
-  apiV1Router.post('/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, addActivity , attachmentApi.add);
   apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.uploadProfileImage);
   apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.uploadProfileImage);
   apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachmentApi.remove);
   apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachmentApi.remove);
   apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.removeProfileImage);
   apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.removeProfileImage);
-  apiV1Router.get('/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachmentApi.limit);
 
 
   // API v1
   // API v1
   app.use('/_api', unavailableWhenMaintenanceModeForApi, apiV1Router);
   app.use('/_api', unavailableWhenMaintenanceModeForApi, apiV1Router);

+ 35 - 1
apps/app/src/server/routes/page.js

@@ -137,7 +137,7 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:page');
   const debug = require('debug')('growi:routes:page');
   const logger = loggerFactory('growi:routes:page');
   const logger = loggerFactory('growi:routes:page');
 
 
-  const { pathUtils } = require('@growi/core/dist/utils');
+  const { pathUtils, pagePathUtils } = require('@growi/core/dist/utils');
 
 
   const Page = crowi.model('Page');
   const Page = crowi.model('Page');
   const User = crowi.model('User');
   const User = crowi.model('User');
@@ -330,6 +330,11 @@ module.exports = function(crowi, app) {
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const slackChannels = req.body.slackChannels || null;
     const slackChannels = req.body.slackChannels || null;
 
 
+    // TODO: remove in https://redmine.weseek.co.jp/issues/136136
+    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
+      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
+    }
+
     if (body === null || pagePath === null) {
     if (body === null || pagePath === null) {
       return res.json(ApiResponse.error('Parameters body and path are required.'));
       return res.json(ApiResponse.error('Parameters body and path are required.'));
     }
     }
@@ -449,6 +454,11 @@ module.exports = function(crowi, app) {
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const slackChannels = req.body.slackChannels || null;
     const slackChannels = req.body.slackChannels || null;
 
 
+    // TODO: remove in https://redmine.weseek.co.jp/issues/136140
+    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
+      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
+    }
+
     if (pageId === null || pageBody === null || revisionId === null) {
     if (pageId === null || pageBody === null || revisionId === null) {
       return res.json(ApiResponse.error('page_id, body and revision_id are required.'));
       return res.json(ApiResponse.error('page_id, body and revision_id are required.'));
     }
     }
@@ -751,6 +761,16 @@ module.exports = function(crowi, app) {
         if (!crowi.pageService.canDeleteCompletely(page.path, creator, req.user, isRecursively)) {
         if (!crowi.pageService.canDeleteCompletely(page.path, creator, req.user, isRecursively)) {
           return res.json(ApiResponse.error('You can not delete this page completely', 'user_not_admin'));
           return res.json(ApiResponse.error('You can not delete this page completely', 'user_not_admin'));
         }
         }
+
+        if (pagePathUtils.isUsersHomepage(page.path)) {
+          if (!crowi.pageService.canDeleteUserHomepageByConfig()) {
+            return res.json(ApiResponse.error('Could not delete user homepage'));
+          }
+          if (!await crowi.pageService.isUsersHomepageOwnerAbsent(page.path)) {
+            return res.json(ApiResponse.error('Could not delete user homepage'));
+          }
+        }
+
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively, false, activityParameters);
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively, false, activityParameters);
       }
       }
       else {
       else {
@@ -768,6 +788,15 @@ module.exports = function(crowi, app) {
           return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
           return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
         }
         }
 
 
+        if (pagePathUtils.isUsersHomepage(page.path)) {
+          if (!crowi.pageService.canDeleteUserHomepageByConfig()) {
+            return res.json(ApiResponse.error('Could not delete user homepage'));
+          }
+          if (!await crowi.pageService.isUsersHomepageOwnerAbsent(page.path)) {
+            return res.json(ApiResponse.error('Could not delete user homepage'));
+          }
+        }
+
         await crowi.pageService.deletePage(page, req.user, options, isRecursively, activityParameters);
         await crowi.pageService.deletePage(page, req.user, options, isRecursively, activityParameters);
       }
       }
     }
     }
@@ -897,6 +926,11 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
     }
     }
 
 
+    // TODO: remove in https://redmine.weseek.co.jp/issues/136139
+    if (page.grantedGroups != null && page.grantedGroups.length > 1) {
+      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
+    }
+
     // check whether path starts slash
     // check whether path starts slash
     newPagePath = pathUtils.addHeadingSlash(newPagePath);
     newPagePath = pathUtils.addHeadingSlash(newPagePath);
 
 

+ 13 - 14
apps/app/src/server/service/file-uploader/aws.ts

@@ -190,7 +190,6 @@ class AwsFileUploader extends AbstractFileUploader {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
 
 
-
     const s3 = S3Factory();
     const s3 = S3Factory();
     const awsConfig = getAwsConfig();
     const awsConfig = getAwsConfig();
     const filePath = getFilePathOnStorage(attachment);
     const filePath = getFilePathOnStorage(attachment);
@@ -198,13 +197,13 @@ class AwsFileUploader extends AbstractFileUploader {
 
 
     // issue signed url (default: expires 120 seconds)
     // issue signed url (default: expires 120 seconds)
     // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
     // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
-    // const isDownload = opts?.download ?? false;
-    // const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    const isDownload = opts?.download ?? false;
+    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
     const params: GetObjectCommandInput = {
     const params: GetObjectCommandInput = {
       Bucket: awsConfig.bucket,
       Bucket: awsConfig.bucket,
       Key: filePath,
       Key: filePath,
-      // ResponseContentType: contentHeaders.contentType?.value.toString(),
-      // ResponseContentDisposition: contentHeaders.contentDisposition?.value.toString(),
+      ResponseContentType: contentHeaders.contentType?.value.toString(),
+      ResponseContentDisposition: contentHeaders.contentDisposition?.value.toString(),
     };
     };
     const signedUrl = await getSignedUrl(s3, new GetObjectCommand(params), {
     const signedUrl = await getSignedUrl(s3, new GetObjectCommand(params), {
       expiresIn: lifetimeSecForTemporaryUrl,
       expiresIn: lifetimeSecForTemporaryUrl,
@@ -288,30 +287,30 @@ module.exports = (crowi) => {
     const awsConfig = getAwsConfig();
     const awsConfig = getAwsConfig();
 
 
     const filePath = getFilePathOnStorage(attachment);
     const filePath = getFilePathOnStorage(attachment);
-    const params = {
+    const contentHeaders = new ContentHeaders(attachment);
+
+    return s3.send(new PutObjectCommand({
       Bucket: awsConfig.bucket,
       Bucket: awsConfig.bucket,
-      ContentType: attachment.fileFormat,
       Key: filePath,
       Key: filePath,
       Body: fileStream,
       Body: fileStream,
       ACL: ObjectCannedACL.public_read,
       ACL: ObjectCannedACL.public_read,
-    };
-
-    return s3.send(new PutObjectCommand(params));
+      // put type and the file name for reference information when uploading
+      ContentType: contentHeaders.contentType?.value.toString(),
+      ContentDisposition: contentHeaders.contentDisposition?.value.toString(),
+    }));
   };
   };
 
 
   lib.saveFile = async function({ filePath, contentType, data }) {
   lib.saveFile = async function({ filePath, contentType, data }) {
     const s3 = S3Factory();
     const s3 = S3Factory();
     const awsConfig = getAwsConfig();
     const awsConfig = getAwsConfig();
 
 
-    const params = {
+    return s3.send(new PutObjectCommand({
       Bucket: awsConfig.bucket,
       Bucket: awsConfig.bucket,
       ContentType: contentType,
       ContentType: contentType,
       Key: filePath,
       Key: filePath,
       Body: data,
       Body: data,
       ACL: ObjectCannedACL.public_read,
       ACL: ObjectCannedACL.public_read,
-    };
-
-    return s3.send(new PutObjectCommand(params));
+    }));
   };
   };
 
 
   (lib as any).checkLimit = async function(uploadFileSize) {
   (lib as any).checkLimit = async function(uploadFileSize) {

+ 45 - 52
apps/app/src/server/service/file-uploader/azure.ts

@@ -1,19 +1,16 @@
-import path from 'path';
-
 import { ClientSecretCredential, TokenCredential } from '@azure/identity';
 import { ClientSecretCredential, TokenCredential } from '@azure/identity';
 import {
 import {
+  generateBlobSASQueryParameters,
   BlobServiceClient,
   BlobServiceClient,
   BlobClient,
   BlobClient,
   BlockBlobClient,
   BlockBlobClient,
   BlobDeleteOptions,
   BlobDeleteOptions,
   ContainerClient,
   ContainerClient,
-  generateBlobSASQueryParameters,
   ContainerSASPermissions,
   ContainerSASPermissions,
   SASProtocol,
   SASProtocol,
   type BlobDeleteIfExistsResponse,
   type BlobDeleteIfExistsResponse,
   type BlockBlobUploadResponse,
   type BlockBlobUploadResponse,
   type BlockBlobParallelUploadOptions,
   type BlockBlobParallelUploadOptions,
-  type BlockBlobUploadStreamOptions,
 } from '@azure/storage-blob';
 } from '@azure/storage-blob';
 
 
 import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
 import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
@@ -62,33 +59,6 @@ async function getContainerClient(): Promise<ContainerClient> {
   return blobServiceClient.getContainerClient(containerName);
   return blobServiceClient.getContainerClient(containerName);
 }
 }
 
 
-// Server creates User Delegation SAS Token for container
-// https://learn.microsoft.com/ja-jp/azure/storage/blobs/storage-blob-create-user-delegation-sas-javascript
-async function getSasToken(lifetimeSec) {
-  const { accountName, containerName } = getAzureConfig();
-  const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
-
-  const now = Date.now();
-  const startsOn = new Date(now - 30 * 1000);
-  const expiresOn = new Date(now + lifetimeSec * 1000);
-  const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);
-
-  // https://github.com/Azure/azure-sdk-for-js/blob/d4d55f73/sdk/storage/storage-blob/src/ContainerSASPermissions.ts#L24
-  // r:read, a:add, c:create, w:write, d:delete, l:list
-  const containerPermissionsForAnonymousUser = 'rl';
-  const sasOptions = {
-    containerName,
-    permissions: ContainerSASPermissions.parse(containerPermissionsForAnonymousUser),
-    protocol: SASProtocol.HttpsAndHttp,
-    startsOn,
-    expiresOn,
-  };
-
-  const sasToken = generateBlobSASQueryParameters(sasOptions, userDelegationKey, accountName).toString();
-
-  return sasToken;
-}
-
 function getFilePathOnStorage(attachment) {
 function getFilePathOnStorage(attachment) {
   const dirName = (attachment.page != null) ? 'attachment' : 'user';
   const dirName = (attachment.page != null) ? 'attachment' : 'user';
   return urljoin(dirName, attachment.fileName);
   return urljoin(dirName, attachment.fileName);
@@ -165,27 +135,51 @@ class AzureFileUploader extends AbstractFileUploader {
 
 
   /**
   /**
    * @inheritDoc
    * @inheritDoc
+   * @see https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blob-create-user-delegation-sas-javascript
    */
    */
   override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
   override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
     if (!this.getIsUploadable()) {
     if (!this.getIsUploadable()) {
       throw new Error('Azure Blob is not configured.');
       throw new Error('Azure Blob is not configured.');
     }
     }
 
 
-    const containerClient = await getContainerClient();
-    const filePath = getFilePathOnStorage(attachment);
-    const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
     const lifetimeSecForTemporaryUrl = configManager.getConfig('crowi', 'azure:lifetimeSecForTemporaryUrl');
     const lifetimeSecForTemporaryUrl = configManager.getConfig('crowi', 'azure:lifetimeSecForTemporaryUrl');
 
 
-    const sasToken = await getSasToken(lifetimeSecForTemporaryUrl);
-    const signedUrl = `${blockBlobClient.url}?${sasToken}`;
-
-    // TODO: re-impl using generateSasUrl
-    // const isDownload = opts?.download ?? false;
-    // const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
-    // const signedUrl = blockBlobClient.generateSasUrl({
-    //   contentType: contentHeaders.contentType?.value.toString(),
-    //   contentDisposition: contentHeaders.contentDisposition?.value.toString(),
-    // });
+    const url = await (async() => {
+      const containerClient = await getContainerClient();
+      const filePath = getFilePathOnStorage(attachment);
+      const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
+      return blockBlobClient.url;
+    })();
+
+    const sasToken = await (async() => {
+      const { accountName, containerName } = getAzureConfig();
+      const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
+
+      const now = Date.now();
+      const startsOn = new Date(now - 30 * 1000);
+      const expiresOn = new Date(now + lifetimeSecForTemporaryUrl * 1000);
+      const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);
+
+      const isDownload = opts?.download ?? false;
+      const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+
+      // https://github.com/Azure/azure-sdk-for-js/blob/d4d55f73/sdk/storage/storage-blob/src/ContainerSASPermissions.ts#L24
+      // r:read, a:add, c:create, w:write, d:delete, l:list
+      const containerPermissionsForAnonymousUser = 'rl';
+      const sasOptions = {
+        containerName,
+        permissions: ContainerSASPermissions.parse(containerPermissionsForAnonymousUser),
+        protocol: SASProtocol.HttpsAndHttp,
+        startsOn,
+        expiresOn,
+        contentType: contentHeaders.contentType?.value.toString(),
+        contentDisposition: contentHeaders.contentDisposition?.value.toString(),
+      };
+
+      return generateBlobSASQueryParameters(sasOptions, userDelegationKey, accountName).toString();
+    })();
+
+    const signedUrl = `${url}?${sasToken}`;
 
 
     return {
     return {
       url: signedUrl,
       url: signedUrl,
@@ -233,15 +227,15 @@ module.exports = (crowi) => {
     const filePath = getFilePathOnStorage(attachment);
     const filePath = getFilePathOnStorage(attachment);
     const containerClient = await getContainerClient();
     const containerClient = await getContainerClient();
     const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
     const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
-    const DEFAULT_BLOCK_BUFFER_SIZE_BYTES: number = 8 * 1024 * 1024; // 8MB
-    const DEFAULT_MAX_CONCURRENCY = 5;
-    const options: BlockBlobUploadStreamOptions = {
+    const contentHeaders = new ContentHeaders(attachment);
+
+    return blockBlobClient.uploadStream(readStream, undefined, undefined, {
       blobHTTPHeaders: {
       blobHTTPHeaders: {
-        blobContentType: attachment.fileFormat,
-        blobContentDisposition: `attachment;filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
+        // put type and the file name for reference information when uploading
+        blobContentType: contentHeaders.contentType?.value.toString(),
+        blobContentDisposition: contentHeaders.contentDisposition?.value.toString(),
       },
       },
-    };
-    return blockBlobClient.uploadStream(readStream, DEFAULT_BLOCK_BUFFER_SIZE_BYTES, DEFAULT_MAX_CONCURRENCY, options);
+    });
   };
   };
 
 
   lib.saveFile = async function({ filePath, contentType, data }) {
   lib.saveFile = async function({ filePath, contentType, data }) {
@@ -250,7 +244,6 @@ module.exports = (crowi) => {
     const options: BlockBlobParallelUploadOptions = {
     const options: BlockBlobParallelUploadOptions = {
       blobHTTPHeaders: {
       blobHTTPHeaders: {
         blobContentType: contentType,
         blobContentType: contentType,
-        blobContentDisposition: `attachment;filename*=UTF-8''${encodeURIComponent(path.basename(filePath))}`,
       },
       },
     };
     };
     const blockBlobUploadResponse: BlockBlobUploadResponse = await blockBlobClient.upload(data, data.length, options);
     const blockBlobUploadResponse: BlockBlobUploadResponse = await blockBlobClient.upload(data, data.length, options);

+ 10 - 8
apps/app/src/server/service/file-uploader/gcs.ts

@@ -145,13 +145,13 @@ class GcsFileUploader extends AbstractFileUploader {
 
 
     // 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 isDownload = opts?.download ?? false;
-    // const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    const isDownload = opts?.download ?? false;
+    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
     const [signedUrl] = await file.getSignedUrl({
     const [signedUrl] = await file.getSignedUrl({
       action: 'read',
       action: 'read',
       expires: Date.now() + lifetimeSecForTemporaryUrl * 1000,
       expires: Date.now() + lifetimeSecForTemporaryUrl * 1000,
-      // responseType: contentHeaders.contentType?.value.toString(),
-      // responseDisposition: contentHeaders.contentDisposition?.value.toString(),
+      responseType: contentHeaders.contentType?.value.toString(),
+      responseDisposition: contentHeaders.contentDisposition?.value.toString(),
     });
     });
 
 
     return {
     return {
@@ -211,11 +211,13 @@ module.exports = function(crowi: Crowi) {
     const gcs = getGcsInstance();
     const gcs = getGcsInstance();
     const myBucket = gcs.bucket(getGcsBucket());
     const myBucket = gcs.bucket(getGcsBucket());
     const filePath = getFilePathOnStorage(attachment);
     const filePath = getFilePathOnStorage(attachment);
-    const options = {
-      destination: filePath,
-    };
+    const contentHeaders = new ContentHeaders(attachment);
 
 
-    return myBucket.upload(fileStream.path, options);
+    return myBucket.upload(fileStream.path, {
+      destination: filePath,
+      // put type and the file name for reference information when uploading
+      contentType: contentHeaders.contentType?.value.toString(),
+    });
   };
   };
 
 
   lib.saveFile = async function({ filePath, contentType, data }) {
   lib.saveFile = async function({ filePath, contentType, data }) {

+ 5 - 1
apps/app/src/server/service/file-uploader/gridfs.ts

@@ -11,6 +11,7 @@ import loggerFactory from '~/utils/logger';
 import { configManager } from '../config-manager';
 import { configManager } from '../config-manager';
 
 
 import { AbstractFileUploader, type TemporaryUrl, type SaveFileParam } from './file-uploader';
 import { AbstractFileUploader, type TemporaryUrl, type SaveFileParam } from './file-uploader';
+import { ContentHeaders } from './utils';
 
 
 const logger = loggerFactory('growi:service:fileUploaderGridfs');
 const logger = loggerFactory('growi:service:fileUploaderGridfs');
 
 
@@ -152,10 +153,13 @@ module.exports = function(crowi) {
   (lib as any).uploadAttachment = async function(fileStream, attachment) {
   (lib as any).uploadAttachment = async function(fileStream, attachment) {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
 
+    const contentHeaders = new ContentHeaders(attachment);
+
     return AttachmentFile.promisifiedWrite(
     return AttachmentFile.promisifiedWrite(
       {
       {
+        // put type and the file name for reference information when uploading
         filename: attachment.fileName,
         filename: attachment.fileName,
-        contentType: attachment.fileFormat,
+        contentType: contentHeaders.contentType?.value.toString(),
       },
       },
       fileStream,
       fileStream,
     );
     );

+ 3 - 3
apps/app/src/server/service/file-uploader/local.ts

@@ -218,10 +218,10 @@ module.exports = function(crowi) {
     const internalPathRoot = configManager.getConfig('crowi', 'fileUpload:local:internalRedirectPath');
     const internalPathRoot = configManager.getConfig('crowi', 'fileUpload:local:internalRedirectPath');
     const internalPath = urljoin(internalPathRoot, relativePath);
     const internalPath = urljoin(internalPathRoot, relativePath);
 
 
-    // const isDownload = opts?.download ?? false;
-    // const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    const isDownload = opts?.download ?? false;
+    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
     applyHeaders(res, [
     applyHeaders(res, [
-      // ...contentHeaders.toExpressHttpHeaders(),
+      ...contentHeaders.toExpressHttpHeaders(),
       { field: 'X-Accel-Redirect', value: internalPath },
       { field: 'X-Accel-Redirect', value: internalPath },
       { field: 'X-Sendfile', value: storagePath },
       { field: 'X-Sendfile', value: storagePath },
     ]);
     ]);

+ 8 - 8
apps/app/src/server/service/page-grant.ts

@@ -493,14 +493,14 @@ class PageGrantService {
       [PageGrant.GRANT_RESTRICTED]: null, // any page can be restricted
       [PageGrant.GRANT_RESTRICTED]: null, // any page can be restricted
     };
     };
 
 
-    const userPossessedGroups = await this.getUserPossessedGroups(user);
+    const userRelatedGroups = await this.getUserRelatedGroups(user);
 
 
     // -- Any grant is allowed if parent is null
     // -- Any grant is allowed if parent is null
     const isAnyGrantApplicable = page.parent == null;
     const isAnyGrantApplicable = page.parent == null;
     if (isAnyGrantApplicable) {
     if (isAnyGrantApplicable) {
       data[PageGrant.GRANT_PUBLIC] = null;
       data[PageGrant.GRANT_PUBLIC] = null;
       data[PageGrant.GRANT_OWNER] = null;
       data[PageGrant.GRANT_OWNER] = null;
-      data[PageGrant.GRANT_USER_GROUP] = { applicableGroups: userPossessedGroups };
+      data[PageGrant.GRANT_USER_GROUP] = { applicableGroups: userRelatedGroups };
       return data;
       return data;
     }
     }
 
 
@@ -516,7 +516,7 @@ class PageGrantService {
     if (grant === PageGrant.GRANT_PUBLIC) {
     if (grant === PageGrant.GRANT_PUBLIC) {
       data[PageGrant.GRANT_PUBLIC] = null;
       data[PageGrant.GRANT_PUBLIC] = null;
       data[PageGrant.GRANT_OWNER] = null;
       data[PageGrant.GRANT_OWNER] = null;
-      data[PageGrant.GRANT_USER_GROUP] = { applicableGroups: userPossessedGroups };
+      data[PageGrant.GRANT_USER_GROUP] = { applicableGroups: userRelatedGroups };
     }
     }
     else if (grant === PageGrant.GRANT_OWNER) {
     else if (grant === PageGrant.GRANT_OWNER) {
       const grantedUser = grantedUsers[0];
       const grantedUser = grantedUsers[0];
@@ -568,14 +568,14 @@ class PageGrantService {
     return data;
     return data;
   }
   }
 
 
-  async getUserPossessedGroups(user) {
-    const userPossessedUserGroups = await UserGroupRelation.findAllGroupsForUser(user);
-    const userPossessedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user);
+  async getUserRelatedGroups(user) {
+    const userRelatedUserGroups = await UserGroupRelation.findAllGroupsForUser(user);
+    const userRelatedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user);
     return [
     return [
-      ...userPossessedUserGroups.map((group) => {
+      ...userRelatedUserGroups.map((group) => {
         return { type: GroupType.userGroup, item: group };
         return { type: GroupType.userGroup, item: group };
       }),
       }),
-      ...userPossessedExternalUserGroups.map((group) => {
+      ...userRelatedExternalUserGroups.map((group) => {
         return { type: GroupType.externalUserGroup, item: group };
         return { type: GroupType.externalUserGroup, item: group };
       }),
       }),
     ];
     ];

+ 111 - 23
apps/app/src/server/service/page.ts

@@ -5,11 +5,10 @@ import type {
   Ref, HasObjectId, IUserHasId, IUser,
   Ref, HasObjectId, IUserHasId, IUser,
   IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup,
   IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup,
 } from '@growi/core';
 } from '@growi/core';
-import { PageGrant, PageStatus } from '@growi/core';
+import { PageGrant, PageStatus, getIdForRef } from '@growi/core';
 import {
 import {
   pagePathUtils, pathUtils,
   pagePathUtils, pathUtils,
 } from '@growi/core/dist/utils';
 } from '@growi/core/dist/utils';
-import { collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { ObjectId, Cursor } from 'mongoose';
 import mongoose, { ObjectId, Cursor } from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 import streamToPromise from 'stream-to-promise';
@@ -21,6 +20,7 @@ import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import {
 import {
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
 } from '~/interfaces/page-delete-config';
 } from '~/interfaces/page-delete-config';
+import { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import {
 import {
   type IPageOperationProcessInfo, type IPageOperationProcessData, PageActionStage, PageActionType,
   type IPageOperationProcessInfo, type IPageOperationProcessData, PageActionStage, PageActionType,
 } from '~/interfaces/page-operation';
 } from '~/interfaces/page-operation';
@@ -29,7 +29,6 @@ import {
   type CreateMethod, type PageCreateOptions, type PageModel, type PageDocument, pushRevision, PageQueryBuilder,
   type CreateMethod, type PageCreateOptions, type PageModel, type PageDocument, pushRevision, PageQueryBuilder,
 } from '~/server/models/page';
 } from '~/server/models/page';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { createBatchStream } from '~/server/util/batch-stream';
-import { getModelSafely } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 
@@ -46,14 +45,15 @@ import UserGroupRelation from '../models/user-group-relation';
 import { V5ConversionError } from '../models/vo/v5-conversion-error';
 import { V5ConversionError } from '../models/vo/v5-conversion-error';
 import { divideByType } from '../util/granted-group';
 import { divideByType } from '../util/granted-group';
 
 
+import { configManager } from './config-manager';
 import { preNotifyService } from './pre-notify';
 import { preNotifyService } from './pre-notify';
 
 
 const debug = require('debug')('growi:services:page');
 const debug = require('debug')('growi:services:page');
 
 
 const logger = loggerFactory('growi:services:page');
 const logger = loggerFactory('growi:services:page');
 const {
 const {
-  isTrashPage, isTopPage, omitDuplicateAreaPageFromPages,
-  isMovablePage, canMoveByPath, isUsersProtectedPages, hasSlash, generateChildrenRegExp,
+  isTrashPage, isTopPage, omitDuplicateAreaPageFromPages, getUsernameByPath, collectAncestorPaths,
+  canMoveByPath, isUsersTopPage, isMovablePage, isUsersHomepage, hasSlash, generateChildrenRegExp,
 } = pagePathUtils;
 } = pagePathUtils;
 
 
 const { addTrailingSlash } = pathUtils;
 const { addTrailingSlash } = pathUtils;
@@ -160,6 +160,8 @@ class PageService {
 
 
     // init
     // init
     this.initPageEvent();
     this.initPageEvent();
+    this.canDeleteCompletely = this.canDeleteCompletely.bind(this);
+    this.canDelete = this.canDelete.bind(this);
   }
   }
 
 
   private initPageEvent() {
   private initPageEvent() {
@@ -172,7 +174,7 @@ class PageService {
   }
   }
 
 
   canDeleteCompletely(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
   canDeleteCompletely(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
-    if (operator == null || isTopPage(path) || isUsersProtectedPages(path)) return false;
+    if (operator == null || isTopPage(path) || isUsersTopPage(path)) return false;
 
 
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const pageRecursiveCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority');
     const pageRecursiveCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority');
@@ -183,7 +185,7 @@ class PageService {
   }
   }
 
 
   canDelete(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
   canDelete(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
-    if (operator == null || isUsersProtectedPages(path) || isTopPage(path)) return false;
+    if (operator == null || isTopPage(path) || isUsersTopPage(path)) return false;
 
 
     const pageDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority');
     const pageDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority');
     const pageRecursiveDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority');
     const pageRecursiveDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority');
@@ -193,6 +195,20 @@ class PageService {
     return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
     return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
   }
   }
 
 
+  canDeleteUserHomepageByConfig(): boolean {
+    return configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled') ?? false;
+  }
+
+  async isUsersHomepageOwnerAbsent(path: string): Promise<boolean> {
+    const User = mongoose.model('User');
+    const username = getUsernameByPath(path);
+    if (username == null) {
+      throw new Error('Cannot found username by path');
+    }
+    const ownerExists = await User.exists({ username });
+    return ownerExists === null;
+  }
+
   private canDeleteLogic(
   private canDeleteLogic(
       creatorId: ObjectIdLike,
       creatorId: ObjectIdLike,
       operator,
       operator,
@@ -225,12 +241,58 @@ class PageService {
     return false;
     return false;
   }
   }
 
 
-  filterPagesByCanDeleteCompletely(pages, user, isRecursively: boolean) {
-    return pages.filter(p => p.isEmpty || this.canDeleteCompletely(p.path, p.creator, user, isRecursively));
+  private async getAbsenseUserHomeList(pages: PageDocument[]): Promise<string[]> {
+    const userHomepages = pages.filter(p => isUsersHomepage(p.path));
+
+    const User = mongoose.model<IUser>('User');
+    const usernames = userHomepages
+      .map(page => getUsernameByPath(page.path))
+      // see: https://zenn.dev/kimuson/articles/filter_safety_type_guard
+      .filter((username): username is Exclude<typeof username, null> => username !== null);
+    const existingUsernames = await User.distinct<string>('username', { username: { $in: usernames } });
+
+    return userHomepages.filter((page) => {
+      const username = getUsernameByPath(page.path);
+      if (username == null) {
+        throw new Error('Cannot found username by path');
+      }
+      return !existingUsernames.includes(username);
+    }).map(p => p.path);
+  }
+
+  private async filterPages(
+      pages: PageDocument[],
+      user: IUserHasId,
+      isRecursively: boolean,
+      canDeleteFunction: (path: string, creatorId: ObjectIdLike, operator: any, isRecursively: boolean) => boolean,
+  ): Promise<PageDocument[]> {
+    const filteredPages = pages.filter(p => p.isEmpty || canDeleteFunction(p.path, p.creator, user, isRecursively));
+
+    if (!this.canDeleteUserHomepageByConfig()) {
+      return filteredPages.filter(p => !isUsersHomepage(p.path));
+    }
+
+    // Confirmation of deletion of user homepages is an asynchronous process,
+    // so it is processed separately for performance optimization.
+    const absenseUserHomeList = await this.getAbsenseUserHomeList(filteredPages);
+
+    const excludeActiveUserHomepage = (path: string) => {
+      if (!isUsersHomepage(path)) {
+        return true;
+      }
+      return absenseUserHomeList.includes(path);
+    };
+
+    return filteredPages
+      .filter(p => excludeActiveUserHomepage(p.path));
+  }
+
+  async filterPagesByCanDeleteCompletely(pages: PageDocument[], user: IUserHasId, isRecursively: boolean): Promise<PageDocument[]> {
+    return this.filterPages(pages, user, isRecursively, this.canDeleteCompletely);
   }
   }
 
 
-  filterPagesByCanDelete(pages, user, isRecursively: boolean) {
-    return pages.filter(p => p.isEmpty || this.canDelete(p.path, p.creator, user, isRecursively));
+  async filterPagesByCanDelete(pages: PageDocument[], user: IUserHasId, isRecursively: boolean): Promise<PageDocument[]> {
+    return this.filterPages(pages, user, isRecursively, this.canDelete);
   }
   }
 
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@@ -258,7 +320,6 @@ class PageService {
         meta: {
         meta: {
           isV5Compatible: isTopPage(page.path) || page.parent != null,
           isV5Compatible: isTopPage(page.path) || page.parent != null,
           isEmpty: page.isEmpty,
           isEmpty: page.isEmpty,
-          isMovable: false,
           isDeletable: false,
           isDeletable: false,
           isAbleToDeleteCompletely: false,
           isAbleToDeleteCompletely: false,
           isRevertible: false,
           isRevertible: false,
@@ -1398,10 +1459,19 @@ class PageService {
       throw new Error('This method does NOT support deleting trashed pages.');
       throw new Error('This method does NOT support deleting trashed pages.');
     }
     }
 
 
-    if (!isMovablePage(page.path)) {
+    if (isTopPage(page.path) || isUsersTopPage(page.path)) {
       throw new Error('Page is not deletable.');
       throw new Error('Page is not deletable.');
     }
     }
 
 
+    if (pagePathUtils.isUsersHomepage(page.path)) {
+      if (!this.crowi.pageService.canDeleteUserHomepageByConfig()) {
+        throw new Error('User Homepage is not deletable.');
+      }
+      if (!await this.crowi.pageService.isUsersHomepageOwnerAbsent(page.path)) {
+        throw new Error('User Homepage is not deletable.');
+      }
+    }
+
     const newPath = Page.getDeletedPageName(page.path);
     const newPath = Page.getDeletedPageName(page.path);
 
 
     const canOperate = await this.crowi.pageOperationService.canOperate(isRecursively, page.path, newPath);
     const canOperate = await this.crowi.pageOperationService.canOperate(isRecursively, page.path, newPath);
@@ -1995,24 +2065,32 @@ class PageService {
    * @throws {Error} - If an error occurs during the deletion process.
    * @throws {Error} - If an error occurs during the deletion process.
    */
    */
   async deleteCompletelyUserHomeBySystem(userHomepagePath: string): Promise<void> {
   async deleteCompletelyUserHomeBySystem(userHomepagePath: string): Promise<void> {
-    const Page = this.crowi.model('Page');
+    if (!isUsersHomepage(userHomepagePath)) {
+      const msg = 'input value is not user homepage path.';
+      logger.error(msg);
+      throw new Error(msg);
+    }
+
+    const Page = mongoose.model<IPage, PageModel>('Page');
     const userHomepage = await Page.findByPath(userHomepagePath, true);
     const userHomepage = await Page.findByPath(userHomepagePath, true);
 
 
     if (userHomepage == null) {
     if (userHomepage == null) {
-      logger.error('user homepage is not found.');
-      return;
+      const msg = 'user homepage is not found.';
+      logger.error(msg);
+      throw new Error(msg);
     }
     }
 
 
     const shouldUseV4Process = this.shouldUseV4Process(userHomepage);
     const shouldUseV4Process = this.shouldUseV4Process(userHomepage);
 
 
     const ids = [userHomepage._id];
     const ids = [userHomepage._id];
     const paths = [userHomepage.path];
     const paths = [userHomepage.path];
+    const parentId = getIdForRef(userHomepage.parent);
 
 
     try {
     try {
       if (!shouldUseV4Process) {
       if (!shouldUseV4Process) {
         // Ensure consistency of ancestors
         // Ensure consistency of ancestors
         const inc = userHomepage.isEmpty ? -userHomepage.descendantCount : -(userHomepage.descendantCount + 1);
         const inc = userHomepage.isEmpty ? -userHomepage.descendantCount : -(userHomepage.descendantCount + 1);
-        await this.updateDescendantCountOfAncestors(userHomepage.parent, inc, true);
+        await this.updateDescendantCountOfAncestors(parentId, inc, true);
       }
       }
 
 
       // Delete the user's homepage
       // Delete the user's homepage
@@ -2020,7 +2098,7 @@ class PageService {
 
 
       if (!shouldUseV4Process) {
       if (!shouldUseV4Process) {
         // Remove leaf empty pages
         // Remove leaf empty pages
-        await Page.removeLeafEmptyPagesRecursively(userHomepage.parent);
+        await Page.removeLeafEmptyPagesRecursively(parentId);
       }
       }
 
 
       if (!userHomepage.isEmpty) {
       if (!userHomepage.isEmpty) {
@@ -2033,7 +2111,7 @@ class PageService {
       // Find descendant pages with system deletion condition
       // Find descendant pages with system deletion condition
       const builder = new PageQueryBuilder(Page.find(), true)
       const builder = new PageQueryBuilder(Page.find(), true)
         .addConditionForSystemDeletion()
         .addConditionForSystemDeletion()
-        .addConditionToListOnlyDescendants(userHomepage.path);
+        .addConditionToListOnlyDescendants(userHomepage.path, {});
 
 
       // Stream processing to delete descendant pages
       // Stream processing to delete descendant pages
       // ────────┤ start │─────────
       // ────────┤ start │─────────
@@ -2277,6 +2355,18 @@ class PageService {
     await PageOperation.findByIdAndDelete(pageOpId);
     await PageOperation.findByIdAndDelete(pageOpId);
   }
   }
 
 
+  /*
+ * get all groups of Page that user is related to
+ */
+  async getUserRelatedGrantedGroups(page: PageDocument, user): Promise<PopulatedGrantedGroup[]> {
+    const populatedPage = await page.populate<{grantedGroups: PopulatedGrantedGroup[] | null}>('grantedGroups.item');
+    const userRelatedGroupIds = [
+      ...(await UserGroupRelation.findAllGroupsForUser(user)).map(ugr => ugr._id.toString()),
+      ...(await ExternalUserGroupRelation.findAllGroupsForUser(user)).map(eugr => eugr._id.toString()),
+    ];
+    return populatedPage.grantedGroups?.filter(group => userRelatedGroupIds.includes(group.item._id.toString())) || [];
+  }
+
   private async revertDeletedPageV4(page, user, options = {}, isRecursively = false) {
   private async revertDeletedPageV4(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const PageTagRelation = this.crowi.model('PageTagRelation');
     const PageTagRelation = this.crowi.model('PageTagRelation');
@@ -2419,13 +2509,12 @@ class PageService {
   }
   }
 
 
   constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
   constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
-    const isMovable = isGuestUser ? false : isMovablePage(page.path);
+    const isDeletable = !(isGuestUser || isTopPage(page.path) || isUsersTopPage(page.path));
 
 
     if (page.isEmpty) {
     if (page.isEmpty) {
       return {
       return {
         isV5Compatible: true,
         isV5Compatible: true,
         isEmpty: true,
         isEmpty: true,
-        isMovable,
         isDeletable: false,
         isDeletable: false,
         isAbleToDeleteCompletely: false,
         isAbleToDeleteCompletely: false,
         isRevertible: false,
         isRevertible: false,
@@ -2442,8 +2531,7 @@ class PageService {
       likerIds: this.extractStringIds(likers),
       likerIds: this.extractStringIds(likers),
       seenUserIds: this.extractStringIds(seenUsers),
       seenUserIds: this.extractStringIds(seenUsers),
       sumOfSeenUsers: page.seenUsers.length,
       sumOfSeenUsers: page.seenUsers.length,
-      isMovable,
-      isDeletable: isMovable,
+      isDeletable,
       isAbleToDeleteCompletely: false,
       isAbleToDeleteCompletely: false,
       isRevertible: isTrashPage(page.path),
       isRevertible: isTrashPage(page.path),
       contentAge: page.getContentAge(),
       contentAge: page.getContentAge(),

+ 50 - 39
apps/app/test/integration/service/external-user-group-sync.test.ts

@@ -99,20 +99,6 @@ const checkSync = async(autoGenerateUserOnGroupSync = true) => {
     provider: 'ldap',
     provider: 'ldap',
     parent: null,
     parent: null,
   });
   });
-  const grandParentGroupRelations = await ExternalUserGroupRelation
-    .find({ relatedGroup: grandParentGroup._id });
-  if (autoGenerateUserOnGroupSync) {
-    expect(grandParentGroupRelations.length).toBe(3);
-    const grandParentGroupUser = (await grandParentGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
-    expect(grandParentGroupUser?.username).toBe('grandParentGroupUser');
-    const parentGroupUser = (await grandParentGroupRelations[1].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
-    expect(parentGroupUser?.username).toBe('parentGroupUser');
-    const childGroupUser = (await grandParentGroupRelations[2].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
-    expect(childGroupUser?.username).toBe('childGroupUser');
-  }
-  else {
-    expect(grandParentGroupRelations.length).toBe(0);
-  }
 
 
   const parentGroup = await ExternalUserGroup.findOne({ name: 'parentGroup' });
   const parentGroup = await ExternalUserGroup.findOne({ name: 'parentGroup' });
   checkGroup(parentGroup, {
   checkGroup(parentGroup, {
@@ -122,18 +108,6 @@ const checkSync = async(autoGenerateUserOnGroupSync = true) => {
     provider: 'ldap',
     provider: 'ldap',
     parent: grandParentGroup._id,
     parent: grandParentGroup._id,
   });
   });
-  const parentGroupRelations = await ExternalUserGroupRelation
-    .find({ relatedGroup: parentGroup._id });
-  if (autoGenerateUserOnGroupSync) {
-    expect(parentGroupRelations.length).toBe(2);
-    const parentGroupUser = (await parentGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
-    expect(parentGroupUser?.username).toBe('parentGroupUser');
-    const childGroupUser = (await parentGroupRelations[1].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
-    expect(childGroupUser?.username).toBe('childGroupUser');
-  }
-  else {
-    expect(parentGroupRelations.length).toBe(0);
-  }
 
 
   const childGroup = await ExternalUserGroup.findOne({ name: 'childGroup' });
   const childGroup = await ExternalUserGroup.findOne({ name: 'childGroup' });
   checkGroup(childGroup, {
   checkGroup(childGroup, {
@@ -143,16 +117,6 @@ const checkSync = async(autoGenerateUserOnGroupSync = true) => {
     provider: 'ldap',
     provider: 'ldap',
     parent: parentGroup._id,
     parent: parentGroup._id,
   });
   });
-  const childGroupRelations = await ExternalUserGroupRelation
-    .find({ relatedGroup: childGroup._id });
-  if (autoGenerateUserOnGroupSync) {
-    expect(childGroupRelations.length).toBe(1);
-    const childGroupUser = (await childGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
-    expect(childGroupUser?.username).toBe('childGroupUser');
-  }
-  else {
-    expect(childGroupRelations.length).toBe(0);
-  }
 
 
   const previouslySyncedGroup = await ExternalUserGroup.findOne({ name: 'previouslySyncedGroup' });
   const previouslySyncedGroup = await ExternalUserGroup.findOne({ name: 'previouslySyncedGroup' });
   checkGroup(previouslySyncedGroup, {
   checkGroup(previouslySyncedGroup, {
@@ -162,14 +126,53 @@ const checkSync = async(autoGenerateUserOnGroupSync = true) => {
     provider: 'ldap',
     provider: 'ldap',
     parent: null,
     parent: null,
   });
   });
+
+  const grandParentGroupRelations = await ExternalUserGroupRelation
+    .find({ relatedGroup: grandParentGroup._id });
+  const parentGroupRelations = await ExternalUserGroupRelation
+    .find({ relatedGroup: parentGroup._id });
+  const childGroupRelations = await ExternalUserGroupRelation
+    .find({ relatedGroup: childGroup._id });
   const previouslySyncedGroupRelations = await ExternalUserGroupRelation
   const previouslySyncedGroupRelations = await ExternalUserGroupRelation
     .find({ relatedGroup: previouslySyncedGroup._id });
     .find({ relatedGroup: previouslySyncedGroup._id });
+
   if (autoGenerateUserOnGroupSync) {
   if (autoGenerateUserOnGroupSync) {
+    expect(grandParentGroupRelations.length).toBe(3);
+    const populatedGrandParentGroupRelations = await Promise.all(grandParentGroupRelations.map((relation) => {
+      return relation.populate<{relatedUser: IUserHasId}>('relatedUser');
+    }));
+    expect(populatedGrandParentGroupRelations[0].relatedUser.username).toBe('grandParentGroupUser');
+    expect(populatedGrandParentGroupRelations[1].relatedUser.username).toBe('parentGroupUser');
+    expect(populatedGrandParentGroupRelations[2].relatedUser.username).toBe('childGroupUser');
+
+    expect(parentGroupRelations.length).toBe(2);
+    const populatedParentGroupRelations = await Promise.all(parentGroupRelations.map((relation) => {
+      return relation.populate<{relatedUser: IUserHasId}>('relatedUser');
+    }));
+    expect(populatedParentGroupRelations[0].relatedUser.username).toBe('parentGroupUser');
+    expect(populatedParentGroupRelations[1].relatedUser.username).toBe('childGroupUser');
+
+    expect(childGroupRelations.length).toBe(1);
+    const childGroupUser = (await childGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
+    expect(childGroupUser?.username).toBe('childGroupUser');
+
     expect(previouslySyncedGroupRelations.length).toBe(1);
     expect(previouslySyncedGroupRelations.length).toBe(1);
     const previouslySyncedGroupUser = (await previouslySyncedGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
     const previouslySyncedGroupUser = (await previouslySyncedGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
     expect(previouslySyncedGroupUser?.username).toBe('previouslySyncedGroupUser');
     expect(previouslySyncedGroupUser?.username).toBe('previouslySyncedGroupUser');
+
+    const userPages = await mongoose.model('Page').find({
+      path: {
+        $in: [
+          '/user/childGroupUser', '/user/parentGroupUser', '/user/grandParentGroupUser', '/user/previouslySyncedGroupUser',
+        ],
+      },
+    });
+    expect(userPages.length).toBe(4);
   }
   }
   else {
   else {
+    expect(grandParentGroupRelations.length).toBe(0);
+    expect(parentGroupRelations.length).toBe(0);
+    expect(childGroupRelations.length).toBe(0);
     expect(previouslySyncedGroupRelations.length).toBe(0);
     expect(previouslySyncedGroupRelations.length).toBe(0);
   }
   }
 };
 };
@@ -179,6 +182,7 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
 
 
   beforeAll(async() => {
   beforeAll(async() => {
     crowi = await getInstance();
     crowi = await getInstance();
+    await configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
     const passportService = new PassportService(crowi);
     const passportService = new PassportService(crowi);
     instanciate(passportService);
     instanciate(passportService);
   });
   });
@@ -198,6 +202,13 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
     await mongoose.model('User')
     await mongoose.model('User')
       .deleteMany({ username: { $in: ['childGroupUser', 'parentGroupUser', 'grandParentGroupUser', 'previouslySyncedGroupUser'] } });
       .deleteMany({ username: { $in: ['childGroupUser', 'parentGroupUser', 'grandParentGroupUser', 'previouslySyncedGroupUser'] } });
     await ExternalAccount.deleteMany({ accountId: { $in: ['childGroupUser', 'parentGroupUser', 'grandParentGroupUser', 'previouslySyncedGroupUser'] } });
     await ExternalAccount.deleteMany({ accountId: { $in: ['childGroupUser', 'parentGroupUser', 'grandParentGroupUser', 'previouslySyncedGroupUser'] } });
+    await mongoose.model('Page').deleteMany({
+      path: {
+        $in: [
+          '/user/childGroupUser', '/user/parentGroupUser', '/user/grandParentGroupUser', '/user/previouslySyncedGroupUser',
+        ],
+      },
+    });
   });
   });
 
 
   describe('When autoGenerateUserOnGroupSync is true', () => {
   describe('When autoGenerateUserOnGroupSync is true', () => {
@@ -207,7 +218,7 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
     };
     };
 
 
     beforeAll(async() => {
     beforeAll(async() => {
-      await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
+      await configManager.updateConfigsInTheSameNamespace('crowi', configParams);
     });
     });
 
 
     // eslint-disable-next-line jest/expect-expect
     // eslint-disable-next-line jest/expect-expect
@@ -224,7 +235,7 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
     };
     };
 
 
     beforeAll(async() => {
     beforeAll(async() => {
-      await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
+      await configManager.updateConfigsInTheSameNamespace('crowi', configParams);
     });
     });
 
 
     // eslint-disable-next-line jest/expect-expect
     // eslint-disable-next-line jest/expect-expect
@@ -241,7 +252,7 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
     };
     };
 
 
     beforeAll(async() => {
     beforeAll(async() => {
-      await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
+      await configManager.updateConfigsInTheSameNamespace('crowi', configParams);
 
 
       const groupId = new Types.ObjectId();
       const groupId = new Types.ObjectId();
       const userId = new Types.ObjectId();
       const userId = new Types.ObjectId();

+ 16 - 16
apps/app/test/integration/service/page-grant.test.js

@@ -532,13 +532,13 @@ describe('PageGrantService', () => {
 
 
     // parent property of all private pages is null
     // parent property of all private pages is null
     test('Any grant is allowed if parent is null', async() => {
     test('Any grant is allowed if parent is null', async() => {
-      const userPossessedUserGroups = await UserGroupRelation.findAllGroupsForUser(user1);
-      const userPossessedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user1);
-      const userPossessedGroups = [
-        ...userPossessedUserGroups.map((group) => {
+      const userRelatedUserGroups = await UserGroupRelation.findAllGroupsForUser(user1);
+      const userRelatedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user1);
+      const userRelatedGroups = [
+        ...userRelatedUserGroups.map((group) => {
           return { type: GroupType.userGroup, item: group };
           return { type: GroupType.userGroup, item: group };
         }),
         }),
-        ...userPossessedExternalUserGroups.map((group) => {
+        ...userRelatedExternalUserGroups.map((group) => {
           return { type: GroupType.externalUserGroup, item: group };
           return { type: GroupType.externalUserGroup, item: group };
         }),
         }),
       ];
       ];
@@ -551,7 +551,7 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userRelatedGroups },
         },
         },
       );
       );
 
 
@@ -563,7 +563,7 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userRelatedGroups },
         },
         },
       );
       );
 
 
@@ -575,20 +575,20 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userRelatedGroups },
         },
         },
       );
       );
     });
     });
 
 
 
 
     test('Any grant is allowed if parent is public', async() => {
     test('Any grant is allowed if parent is public', async() => {
-      const userPossessedUserGroups = await UserGroupRelation.findAllGroupsForUser(user1);
-      const userPossessedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user1);
-      const userPossessedGroups = [
-        ...userPossessedUserGroups.map((group) => {
+      const userRelatedUserGroups = await UserGroupRelation.findAllGroupsForUser(user1);
+      const userRelatedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user1);
+      const userRelatedGroups = [
+        ...userRelatedUserGroups.map((group) => {
           return { type: GroupType.userGroup, item: group };
           return { type: GroupType.userGroup, item: group };
         }),
         }),
-        ...userPossessedExternalUserGroups.map((group) => {
+        ...userRelatedExternalUserGroups.map((group) => {
           return { type: GroupType.externalUserGroup, item: group };
           return { type: GroupType.externalUserGroup, item: group };
         }),
         }),
       ];
       ];
@@ -601,7 +601,7 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userRelatedGroups },
         },
         },
       );
       );
 
 
@@ -613,7 +613,7 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userRelatedGroups },
         },
         },
       );
       );
 
 
@@ -625,7 +625,7 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userRelatedGroups },
         },
         },
       );
       );
     });
     });

+ 1 - 1
package.json

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

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

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

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

@@ -1,9 +1,9 @@
 import {
 import {
-  isMovablePage, convertToNewAffiliationPath, isCreatablePage, omitDuplicateAreaPathFromPaths, getUsernameByPath,
+  isMovablePage, isTopPage, isUsersProtectedPages, convertToNewAffiliationPath, isCreatablePage, omitDuplicateAreaPathFromPaths, getUsernameByPath,
 } from './index';
 } from './index';
 
 
 describe.concurrent('isMovablePage test', () => {
 describe.concurrent('isMovablePage test', () => {
-  test('should decide deletable or not', () => {
+  test('should decide movable or not', () => {
     expect(isMovablePage('/')).toBeFalsy();
     expect(isMovablePage('/')).toBeFalsy();
     expect(isMovablePage('/hoge')).toBeTruthy();
     expect(isMovablePage('/hoge')).toBeTruthy();
     expect(isMovablePage('/user')).toBeFalsy();
     expect(isMovablePage('/user')).toBeFalsy();
@@ -13,6 +13,24 @@ describe.concurrent('isMovablePage test', () => {
   });
   });
 });
 });
 
 
+describe.concurrent('isTopPage test', () => {
+  test('should decide deletable or not', () => {
+    expect(isTopPage('/')).toBeTruthy();
+    expect(isTopPage('/hoge')).toBeFalsy();
+    expect(isTopPage('/user/xxx/hoge')).toBeFalsy();
+  });
+});
+
+describe.concurrent('isUsersProtectedPages test', () => {
+  test('Should decide users protected pages or not', () => {
+    expect(isUsersProtectedPages('/hoge')).toBeFalsy();
+    expect(isUsersProtectedPages('/user')).toBeTruthy();
+    expect(isUsersProtectedPages('/user/xxx')).toBeTruthy();
+    expect(isUsersProtectedPages('/user/xxx123')).toBeTruthy();
+    expect(isUsersProtectedPages('/user/xxx/hoge')).toBeFalsy();
+  });
+});
+
 describe.concurrent('convertToNewAffiliationPath test', () => {
 describe.concurrent('convertToNewAffiliationPath test', () => {
   test.concurrent('Child path is not converted normally', () => {
   test.concurrent('Child path is not converted normally', () => {
     const result = convertToNewAffiliationPath('parent/', 'parent2/', 'parent/child');
     const result = convertToNewAffiliationPath('parent/', 'parent2/', 'parent/child');

+ 0 - 7
packages/remark-drawio/src/components/DrawioViewer.module.scss

@@ -2,11 +2,4 @@
   margin: 20px 0;
   margin: 20px 0;
   border: 1px solid transparent;
   border: 1px solid transparent;
   border-radius: 4px;
   border-radius: 4px;
-
-  .geDiagramContainer {
-    // centering
-    margin-right: auto;
-    margin-left: auto;
-  }
-
 }
 }

+ 27 - 1
packages/remark-drawio/src/components/DrawioViewer.tsx

@@ -56,16 +56,22 @@ export const DrawioViewer = memo((props: DrawioViewerProps): JSX.Element => {
       return;
       return;
     }
     }
 
 
-    const mxgraphs = drawioContainerRef.current.getElementsByClassName('mxgraph');
+    const mxgraphs = drawioContainerRef.current.getElementsByClassName('mxgraph') as HTMLCollectionOf<HTMLElement>;
     if (mxgraphs.length > 0) {
     if (mxgraphs.length > 0) {
       // This component should have only one '.mxgraph' element
       // This component should have only one '.mxgraph' element
       const div = mxgraphs[0];
       const div = mxgraphs[0];
 
 
       if (div != null) {
       if (div != null) {
         div.innerHTML = '';
         div.innerHTML = '';
+        div.style.width = '';
+        div.style.height = '';
 
 
         // render diagram with createViewerForElement
         // render diagram with createViewerForElement
         try {
         try {
+          GraphViewer.useResizeSensor = false;
+          GraphViewer.prototype.checkVisibleState = false;
+          GraphViewer.prototype.lightboxZIndex = 1055; // set $zindex-modal
+          GraphViewer.prototype.toolbarZIndex = 1055; // set $zindex-modal
           GraphViewer.createViewerForElement(div);
           GraphViewer.createViewerForElement(div);
         }
         }
         catch (err) {
         catch (err) {
@@ -139,6 +145,26 @@ export const DrawioViewer = memo((props: DrawioViewerProps): JSX.Element => {
   }, [onRenderingUpdated]);
   }, [onRenderingUpdated]);
   // *******************************  end  *******************************
   // *******************************  end  *******************************
 
 
+  // *******************  detect container is resized  *******************
+  useEffect(() => {
+    if (drawioContainerRef.current == null) {
+      return;
+    }
+
+    const observer = new ResizeObserver((entries) => {
+      entries.forEach(() => {
+        // setElementWidth(el.contentRect.width);
+        onRenderingStart?.();
+        renderDrawioWithDebounce();
+      });
+    });
+    observer.observe(drawioContainerRef.current);
+    return () => {
+      observer.disconnect();
+    };
+  }, [onRenderingStart, renderDrawioWithDebounce]);
+  // *******************************  end  *******************************
+
   return (
   return (
     <div
     <div
       key={`drawio-viewer-${diagramIndex}`}
       key={`drawio-viewer-${diagramIndex}`}

+ 4 - 4
yarn.lock

@@ -16532,10 +16532,10 @@ vite-tsconfig-paths@^4.2.0:
     globrex "^0.1.2"
     globrex "^0.1.2"
     tsconfck "^2.1.0"
     tsconfck "^2.1.0"
 
 
-"vite@^3.0.0 || ^4.0.0 || ^5.0.0-0", "vite@^3.1.0 || ^4.0.0 || ^5.0.0-0", vite@^4.5.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.0.tgz#ec406295b4167ac3bc23e26f9c8ff559287cff26"
-  integrity sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==
+"vite@^3.0.0 || ^4.0.0 || ^5.0.0-0", "vite@^3.1.0 || ^4.0.0 || ^5.0.0-0", vite@^4.5.1:
+  version "4.5.1"
+  resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.1.tgz#3370986e1ed5dbabbf35a6c2e1fb1e18555b968a"
+  integrity sha512-AXXFaAJ8yebyqzoNB9fu2pHoo/nWX+xZlaRwoeYUxEqBO+Zj4msE5G+BhGBll9lYEKv9Hfks52PAF2X7qDYXQA==
   dependencies:
   dependencies:
     esbuild "^0.18.10"
     esbuild "^0.18.10"
     postcss "^8.4.27"
     postcss "^8.4.27"