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

Merge branch 'master' into fix/gw-7953-fix-bookmark-status-on-search-results

ryoji-s 2 лет назад
Родитель
Сommit
a5c6e762df
100 измененных файлов с 1605 добавлено и 1059 удалено
  1. 0 11
      .eslintrc.js
  2. 1 2
      .github/ISSUE_TEMPLATE/bug-report.md
  3. 4 0
      .github/dependabot.yml
  4. 16 16
      .github/release-drafter.yml
  5. 4 3
      .github/workflows/auto-approve.yml
  6. 2 2
      .github/workflows/pr-to-master.yml
  7. 1 1
      .github/workflows/release-slackbot-proxy.yml
  8. 2 2
      .github/workflows/release.yml
  9. 1 2
      .mergify.yml
  10. 41 5
      CHANGELOG.md
  11. 0 11
      apps/app/.eslintrc.js
  12. 1 4
      apps/app/config/migrate-mongo-config.js
  13. 71 0
      apps/app/config/migrate-mongo-config.spec.ts
  14. 0 20
      apps/app/jest.config.js
  15. 14 12
      apps/app/package.json
  16. 8 8
      apps/app/public/static/locales/en_US/admin.json
  17. 1 7
      apps/app/public/static/locales/en_US/translation.json
  18. 7 7
      apps/app/public/static/locales/ja_JP/admin.json
  19. 1 7
      apps/app/public/static/locales/ja_JP/translation.json
  20. 7 7
      apps/app/public/static/locales/zh_CN/admin.json
  21. 1 7
      apps/app/public/static/locales/zh_CN/translation.json
  22. 6 6
      apps/app/src/client/services/AdminUsersContainer.js
  23. 4 2
      apps/app/src/client/util/bookmark-utils.ts
  24. 9 9
      apps/app/src/components/Admin/Users/GrantAdminButton.tsx
  25. 15 15
      apps/app/src/components/Admin/Users/RevokeAdminButton.tsx
  26. 13 13
      apps/app/src/components/Admin/Users/RevokeAdminMenuItem.tsx
  27. 3 3
      apps/app/src/components/Admin/Users/UserMenu.tsx
  28. 40 34
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  29. 7 7
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  30. 14 4
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  31. 5 2
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  32. 1 1
      apps/app/src/components/PageList/PageList.tsx
  33. 3 3
      apps/app/src/components/PageList/PageListItemL.tsx
  34. 2 2
      apps/app/src/components/PageRenameModal.tsx
  35. 21 31
      apps/app/src/components/PageTimeline.tsx
  36. 5 2
      apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx
  37. 3 3
      apps/app/src/components/Sidebar/PageTree/Item.tsx
  38. 5 1
      apps/app/src/components/UsersHomePageFooter.tsx
  39. 6 6
      apps/app/src/interfaces/activity.ts
  40. 10 13
      apps/app/src/interfaces/bookmark-info.ts
  41. 1 3
      apps/app/src/server/console.js
  42. 3 1
      apps/app/src/server/crowi/express-init.js
  43. 3 5
      apps/app/src/server/crowi/index.js
  44. 3 3
      apps/app/src/server/middlewares/exclude-read-only-user.spec.ts
  45. 18 30
      apps/app/src/server/middlewares/safe-redirect.spec.ts
  46. 6 4
      apps/app/src/server/middlewares/safe-redirect.ts
  47. 12 0
      apps/app/src/server/models/.eslintrc.js
  48. 22 72
      apps/app/src/server/models/bookmark-folder.ts
  49. 27 0
      apps/app/src/server/models/eslint-rules-dir/no-populate.js
  50. 25 0
      apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts
  51. 25 0
      apps/app/src/server/models/serializers/bookmark-serializer.js
  52. 9 9
      apps/app/src/server/models/user.js
  53. 61 5
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  54. 17 7
      apps/app/src/server/routes/apiv3/bookmarks.js
  55. 20 20
      apps/app/src/server/routes/apiv3/users.js
  56. 221 0
      apps/app/src/server/service/acl.integ.test.ts
  57. 23 20
      apps/app/src/server/service/config-manager.spec.ts
  58. 75 85
      apps/app/src/server/service/config-manager.ts
  59. 2 2
      apps/app/src/server/service/customize.ts
  60. 4 3
      apps/app/src/server/service/file-uploader-switch.ts
  61. 47 12
      apps/app/src/server/service/file-uploader/aws.ts
  62. 151 0
      apps/app/src/server/service/file-uploader/file-uploader.ts
  63. 3 2
      apps/app/src/server/service/file-uploader/gcs.js
  64. 49 12
      apps/app/src/server/service/file-uploader/gridfs.ts
  65. 3 2
      apps/app/src/server/service/file-uploader/local.js
  66. 3 2
      apps/app/src/server/service/file-uploader/none.js
  67. 0 123
      apps/app/src/server/service/file-uploader/uploader.js
  68. 1 1
      apps/app/src/server/service/g2g-transfer.ts
  69. 4 4
      apps/app/src/server/service/installer.ts
  70. 8 7
      apps/app/src/server/service/slack-integration.ts
  71. 1 5
      apps/app/src/server/util/mongoose-utils.ts
  72. 0 64
      apps/app/src/server/util/slack-legacy.js
  73. 67 0
      apps/app/src/server/util/slack-legacy.ts
  74. 52 0
      apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.spec.ts
  75. 6 7
      apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.ts
  76. 79 0
      apps/app/src/services/renderer/rehype-plugins/relative-links.spec.ts
  77. 16 12
      apps/app/src/services/renderer/rehype-plugins/relative-links.ts
  78. 43 0
      apps/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.spec.ts
  79. 2 2
      apps/app/src/stores/bookmark-folder.ts
  80. 2 7
      apps/app/src/stores/bookmark.ts
  81. 27 0
      apps/app/src/stores/page-timeline.tsx
  82. 3 2
      apps/app/src/utils/page-delete-config.test.ts
  83. 1 1
      apps/app/src/utils/to-array-from-csv.spec.ts
  84. 16 0
      apps/app/test/integration/.eslintrc.js
  85. 1 10
      apps/app/test/integration/global-setup.js
  86. 2 2
      apps/app/test/integration/migrations/20210913153942-migrate-slack-app-integration-schema.test.ts
  87. 2 2
      apps/app/test/integration/service/questionnaire-cron.test.ts
  88. 1 2
      apps/app/test/integration/setup.js
  89. 0 17
      apps/app/test/integration/utils/slack-legacy.test.js
  90. 0 71
      apps/app/test/unit/migrate-mongo-config.test.js
  91. 0 92
      apps/app/test/unit/services/renderer/pukiwiki-like-linker.test.ts
  92. 3 0
      apps/app/tsconfig.json
  93. 14 0
      apps/app/vitest.config.ts
  94. 2 2
      apps/slackbot-proxy/package.json
  95. 23 19
      bin/data-migrations/README.md
  96. 13 17
      bin/data-migrations/src/index.js
  97. 8 0
      bin/data-migrations/src/migrations/custom.js
  98. 8 0
      bin/data-migrations/src/migrations/v60x/bracketlink.js
  99. 6 0
      bin/data-migrations/src/migrations/v60x/csv.js
  100. 6 0
      bin/data-migrations/src/migrations/v60x/drawio.js

+ 0 - 11
.eslintrc.js

@@ -3,15 +3,8 @@ module.exports = {
   extends: [
     'weseek',
     'weseek/typescript',
-    'plugin:jest/recommended',
   ],
-  env: {
-    'jest/globals': true,
-  },
-  globals: {
-  },
   plugins: [
-    'jest',
     'regex',
   ],
   rules: {
@@ -67,10 +60,6 @@ module.exports = {
         FunctionExpression: { body: 1, parameters: 2 },
       },
     ],
-    'jest/no-standalone-expect': [
-      'error',
-      { additionalTestBlockFunctions: ['each.test'] },
-    ],
     'regex/invalid': ['error', [
       {
         regex: '\\?\\<\\!',

+ 1 - 2
.github/ISSUE_TEMPLATE/bug-report.md

@@ -1,8 +1,7 @@
 ---
 name: Bug report
 about: Create a report to help us improve
-title: 'Bug:' 
-labels: bug
+labels: ['phase/new']
 ---
 
 Environment

+ 4 - 0
.github/dependabot.yml

@@ -5,6 +5,8 @@ updates:
     open-pull-requests-limit: 3
     schedule:
       interval: monthly
+    labels:
+      - "type/dependencies"
     commit-message:
       prefix: ci
       include: scope
@@ -14,6 +16,8 @@ updates:
     open-pull-requests-limit: 3
     schedule:
       interval: weekly
+    labels:
+      - "type/dependencies"
     commit-message:
       prefix: ci
       include: scope

+ 16 - 16
.github/release-drafter.yml

@@ -1,35 +1,35 @@
 categories:
   - title: 'BREAKING CHANGES'
     labels:
-      - 'breaking'
+      - 'type/reaking'
   - title: '💎 Features'
     labels:
-      - 'feature'
+      - 'type/feature'
   - title: '🚀 Improvement'
     labels:
-      - 'improvement'
+      - 'type/improvement'
   - title: '🐛 Bug Fixes'
     labels:
-      - 'bug'
+      - 'type/bug'
   - title: '🧰 Maintenance'
     labels:
-      - 'support'
-      - 'dependencies'
+      - 'type/support'
+      - 'type/dependencies'
 category-template: '### $TITLE'
 change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
 autolabeler:
   - label: 'feature'
     branch:
       - '/^feat\/.+/'
-  - label: 'improvement'
+  - label: 'type/improvement'
     branch:
       - '/^imprv\/.+/'
-  - label: 'bug'
+  - label: 'type/bug'
     branch:
       - '/^fix\/.+/'
     title:
       - '/^fix/i'
-  - label: 'support'
+  - label: 'type/support'
     branch:
       - '/^support\/.+/'
     title:
@@ -39,13 +39,13 @@ autolabeler:
       - '/^docs/i'
       - '/^test/i'
 include-labels:
-  - breaking
-  - feature
-  - improvement
-  - bug
-  - support
-  - dependencies
+  - type/breaking
+  - type/feature
+  - type/improvement
+  - type/bug
+  - type/support
+  - type/dependencies
 exclude-labels:
-  - 'exclude from changelog'
+  - 'flag/exclude-from-changelog'
 template: |
   $CHANGES

+ 4 - 3
.github/workflows/dependabot-auto-approve.yml → .github/workflows/auto-approve.yml

@@ -1,5 +1,4 @@
-# by https://zenn.dev/nemuki/articles/dependabot-auto-merge
-name: Auto approve on dependabot PR at patch update
+name: Auto approve PR
 
 on:
   pull_request_target:
@@ -9,7 +8,9 @@ permissions:
   pull-requests: write
 
 jobs:
-  dependabot-auto-approve:
+  # Auto approve on dependabot PR at patch update
+  #   by https://zenn.dev/nemuki/articles/dependabot-auto-merge
+  approve-updating-patch-version:
     runs-on: ubuntu-latest
     if: ${{ github.actor == 'dependabot[bot]' }}
     steps:

+ 2 - 2
.github/workflows/pr-to-master.yml

@@ -19,7 +19,7 @@ jobs:
     runs-on: ubuntu-latest
 
     if: |
-      !contains(github.event.pull_request.labels.*.name, 'exclude from changelog')
+      !contains(github.event.pull_request.labels.*.name, 'flag/exclude-from-changelog')
 
     steps:
       - uses: release-drafter/release-drafter@v5
@@ -32,7 +32,7 @@ jobs:
     runs-on: ubuntu-latest
 
     if: |
-      (!contains( github.event.pull_request.labels.*.name, 'exclude from changelog' ) &&
+      (!contains( github.event.pull_request.labels.*.name, 'flag/exclude-from-changelog' ) &&
         !startsWith( github.head_ref, 'dependabot/' ))
 
     steps:

+ 1 - 1
.github/workflows/release-slackbot-proxy.yml

@@ -135,6 +135,6 @@ jobs:
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         destination_branch: master
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
-        pr_label: exclude from changelog
+        pr_label: flag/exclude-from-changelog
         pr_body: "An automated PR generated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
         github_token: ${{ secrets.GITHUB_TOKEN }}

+ 2 - 2
.github/workflows/release.yml

@@ -118,8 +118,8 @@ jobs:
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         destination_branch: master
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
-        pr_label: exclude from changelog,prepare next version
-        pr_body: "An automated PR generated by create-pr-for-next-rc"
+        pr_label: flag/exclude-from-changelog,type/prepare-next-version
+        pr_body: "[skip ci] An automated PR generated by create-pr-for-next-rc"
         github_token: ${{ secrets.GITHUB_TOKEN }}
 
 

+ 1 - 2
.mergify.yml

@@ -15,8 +15,7 @@ pull_request_rules:
   - name: Automatic merge for Preparing next version
     conditions:
       - author = github-actions[bot]
-      - '#approved-reviews-by >= 1'
-      - label = "prepare next version"
+      - label = "type/prepare-next-version"
     actions:
       merge:
         method: merge

+ 41 - 5
CHANGELOG.md

@@ -1,11 +1,51 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.1.0...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.1.2...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v6.1.2](https://github.com/weseek/growi/compare/v6.1.1...v6.1.2) - 2023-05-25
+
+### 🚀 Improvement
+
+- imprv: Unify whitelist description (#7638) @soumaeda
+- imprv: Refactoring migration script (#7694) @miya
+- imprv: Implement infinite scroll into PageTimeline (#7679) @reiji-h
+
+### 🐛 Bug Fixes
+
+- fix: Hash and search query in the relative link are omitted wrongly (#7697) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Restrict the 'populate' method in model modules (#7689) @yuki-takei
+- support: Refactor LinkEditModal (#7654) @yukendev
+- ci(deps): bump aws-actions/configure-aws-credentials from 1 to 2 (#7620) @dependabot
+- ci(deps): bump hugo19941994/delete-draft-releases from 1.0.0 to 1.0.1 (#7448) @dependabot
+
+## [v6.1.1](https://github.com/weseek/growi/compare/v6.1.0...v6.1.1) - 2023-05-24
+
+### 🐛 Bug Fixes
+
+- fix: Bookmark folders owned by others are accessible for manipulation (#7688) @miya
+- fix: remark-attachment-refs does not work in production (#7681) @yuki-takei
+- fix: User picture of bookmark not showing inside bookmark folder (#7678) @mudana-grune
+- fix: Update name attribute of PageRenameModal.tsx (#7677) @jam411
+- fix: The user's bookmarks are displayed on unrelated user's home (#7668) @miya
+- fix: The user's bookmarks are updated by unrelated user's operation (#7670) @jam411
+
 ## [v6.1.0](https://github.com/weseek/growi/compare/v6.0.15...v6.1.0) - 2023-05-17
 
+### BREAKING CHANGES
+
+- Node.js v14 is no longer supported.
+- Elasticsearch v6 is no longer supported.
+- imprv: Omit clobber prefix (#7627) @yuki-takei
+- support: Omit textlint (#7578) @yuki-takei
+- support: Remove Blockdiag codes (#7576) @yuki-takei
+
+See the upgrading guide for v6.1.x. => [English](https://docs.growi.org/en/admin-guide/upgrading/61x.html) / [Japanese](https://docs.growi.org/ja/admin-guide/upgrading/61x.html)
+
 ### 💎 Features
 
 - feat: Add read-only user feature (#7648) @jam411
@@ -25,7 +65,6 @@
 - imprv: Optimize fonts with next/font (#7633) @yuki-takei
 - imprv: GFM table performance 2 (#7640) @yuki-takei
 - imprv: GFM footnote styles (#7628) @yuki-takei
-- imprv: Omit clobber prefix (#7627) @yuki-takei
 - imprv: GFM table performance (#7619) @yuki-takei
 - imprv: Show unsaved warning when comment not posted (#7603) @arafubeatbox
 - imprv: Suppress UI Flickering for dropdowns (#7608) @jam411
@@ -41,7 +80,6 @@
 - fix: Cursor resetting occurs after updating with the built-in editor (#7644) @yuki-takei
 - fix: Revision schema migration for v5 to v6 (#7637) @yuki-takei
 - fix: Editor not resetting when the same markdown (#7625) @arafubeatbox
-- support: Replaced by IAttachmentHasId (#7629) @reiji-h
 - fix: AlignRight DropdownMenu flickering (#7606) @mudana-grune
 - fix: Not display page list count badge in trash page (#7600) @yukendev
 - fix: Reverted descendant pages do not appear in search results (#7587) @miya
@@ -55,10 +93,8 @@
 - support: Elasticsearch8 (#7592) @miya
 - support: Replaced by IAttachmentHasId (#7629) @reiji-h
 - support: Dedupe packages (#7590) @yuki-takei
-- support: Omit textlint (#7578) @yuki-takei
 - support: Typescriptize CustomNav (#7584) @yuki-takei
 - support: Replaced by IAttachmentHasId (#7629) @reiji-h
-- support: Remove Blockdiag codes (#7576) @yuki-takei
 - support: Migrate to Turborepo (#7417) @yuki-takei
 
 ## [v6.0.15](https://github.com/weseek/growi/compare/v6.0.14...v6.0.15) - 2023-04-10

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

@@ -5,16 +5,6 @@ module.exports = {
   plugins: [
     'regex',
   ],
-  env: {
-    jquery: true,
-  },
-  globals: {
-    $: true,
-    jquery: true,
-    hljs: true,
-    ScrollPosStyler: true,
-    window: true,
-  },
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     'import/resolver': {
@@ -40,7 +30,6 @@ module.exports = {
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     '@typescript-eslint/no-use-before-define': ['warn'],
     '@typescript-eslint/no-this-alias': ['warn'],
-    'jest/no-done-callback': ['warn'],
   },
   overrides: [
     {

+ 1 - 4
apps/app/config/migrate-mongo-config.js

@@ -8,7 +8,7 @@ const isProduction = process.env.NODE_ENV === 'production';
 
 const { URL } = require('url');
 
-const { initMongooseGlobalSettings, getMongoUri, mongoOptions } = isProduction
+const { getMongoUri, mongoOptions } = isProduction
   // eslint-disable-next-line import/extensions, import/no-unresolved
   ? require('../dist/server/util/mongoose-utils')
   : require('../src/server/util/mongoose-utils');
@@ -19,9 +19,6 @@ if (migrationsDir == null) {
   throw new Error('An env var MIGRATIONS_DIR must be set.');
 }
 
-
-initMongooseGlobalSettings();
-
 const mongoUri = getMongoUri();
 
 // parse url

+ 71 - 0
apps/app/config/migrate-mongo-config.spec.ts

@@ -0,0 +1,71 @@
+import {
+  vi,
+  beforeEach,
+  describe, test, expect,
+} from 'vitest';
+
+import mockRequire from 'mock-require';
+
+const { reRequire } = mockRequire;
+
+
+describe('config/migrate-mongo-config.js', () => {
+
+  test.concurrent('throws an error when MIGRATIONS_DIR is not set', () => {
+
+    const getMongoUriMock = vi.fn();
+    const mongoOptionsMock = vi.fn();
+
+    // mock for mongoose-utils
+    mockRequire('../src/server/util/mongoose-utils', {
+      getMongoUri: getMongoUriMock,
+      mongoOptions: mongoOptionsMock,
+    });
+
+    // use reRequire to avoid using module cache
+    const caller = () => reRequire('./migrate-mongo-config');
+
+    expect(caller).toThrow('An env var MIGRATIONS_DIR must be set.');
+
+    mockRequire.stop('../src/server/util/mongoose-utils');
+
+    expect(getMongoUriMock).not.toHaveBeenCalled();
+  });
+
+  describe.concurrent.each`
+    MONGO_URI                                         | expectedDbName
+    ${'mongodb://example.com/growi'}                  | ${'growi'}
+    ${'mongodb://user:pass@example.com/growi'}        | ${'growi'}
+    ${'mongodb://example.com/growi?replicaSet=mySet'} | ${'growi'}
+  `('returns', ({ MONGO_URI, expectedDbName }) => {
+
+    beforeEach(async() => {
+      process.env.MIGRATIONS_DIR = 'testdir/migrations';
+    });
+
+    test(`when 'MONGO_URI' is '${MONGO_URI}`, () => {
+
+      const getMongoUriMock = vi.fn(() => MONGO_URI);
+      const mongoOptionsMock = vi.fn();
+
+      // mock for mongoose-utils
+      mockRequire('../src/server/util/mongoose-utils', {
+        getMongoUri: getMongoUriMock,
+        mongoOptions: mongoOptionsMock,
+      });
+
+      // use reRequire to avoid using module cache
+      const { mongodb, migrationsDir, changelogCollectionName } = reRequire('./migrate-mongo-config');
+
+      mockRequire.stop('../src/server/util/mongoose-utils');
+
+      // expect(getMongoUriMock).toHaveBeenCalledOnce();
+      expect(mongodb.url).toBe(MONGO_URI);
+      expect(mongodb.databaseName).toBe(expectedDbName);
+      expect(mongodb.options).toBe(mongoOptionsMock);
+      expect(migrationsDir).toBe('testdir/migrations');
+      expect(changelogCollectionName).toBe('migrations');
+    });
+  });
+
+});

+ 0 - 20
apps/app/jest.config.js

@@ -13,26 +13,6 @@ module.exports = {
   moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
 
   projects: [
-    {
-      displayName: 'unit',
-
-      transform: {
-        '^.+\\.(t|j)sx?$': '@swc/jest',
-      },
-      // transform ESM to CJS (includes all packages in node_modules)
-      transformIgnorePatterns: [],
-
-      rootDir: '.',
-      roots: ['<rootDir>'],
-      testMatch: ['<rootDir>/test/unit/**/*.test.ts', '<rootDir>/test/unit/**/*.test.js'],
-
-      testEnvironment: 'node',
-
-      // Automatically clear mock calls and instances between every test
-      clearMocks: true,
-      moduleNameMapper: MODULE_NAME_MAPPING,
-
-    },
     {
       displayName: 'server',
 

+ 14 - 12
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.1.1-RC.0",
+  "version": "6.1.3-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -33,7 +33,9 @@
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint": "run-p lint:*",
     "prelint:swagger2openapi": "yarn openapi:v3",
-    "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
+    "test": "run-p test:*",
+    "test:jest": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
+    "test:vitest": "vitest run config src --coverage",
     "jest:run": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
     "//// misc": "",
@@ -59,14 +61,14 @@
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/core": "^6.1.1-RC.0",
-    "@growi/hackmd": "^6.1.1-RC.0",
-    "@growi/preset-themes": "^6.1.1-RC.0",
-    "@growi/remark-attachment-refs": "^6.1.1-RC.0",
-    "@growi/remark-drawio": "^6.1.1-RC.0",
-    "@growi/remark-growi-directive": "^6.1.1-RC.0",
-    "@growi/remark-lsx": "^6.1.1-RC.0",
-    "@growi/slack": "^6.1.1-RC.0",
+    "@growi/core": "^6.1.3-RC.0",
+    "@growi/hackmd": "^6.1.3-RC.0",
+    "@growi/preset-themes": "^6.1.3-RC.0",
+    "@growi/remark-attachment-refs": "^6.1.3-RC.0",
+    "@growi/remark-drawio": "^6.1.3-RC.0",
+    "@growi/remark-growi-directive": "^6.1.3-RC.0",
+    "@growi/remark-lsx": "^6.1.3-RC.0",
+    "@growi/slack": "^6.1.3-RC.0",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
@@ -202,8 +204,8 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
-    "@growi/presentation": "^6.1.1-RC.0",
-    "@growi/ui": "^6.1.1-RC.0",
+    "@growi/presentation": "^6.1.3-RC.0",
+    "@growi/ui": "^6.1.3-RC.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^13.2.3",

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

@@ -290,7 +290,7 @@
     "management_wiki": "Management Wiki",
     "system_information": "System information",
     "wiki_administrator": "Only wiki administrator can access this page",
-    "assign_administrator": "You can assign the selected user to be a wiki administrator on the User Management page using the 'Give admin access' button",
+    "assign_administrator": "You can assign the selected user to be a wiki administrator on the User Management page using the 'Grant admin access' button",
     "package_name": "Package name",
     "specified_version": "Specified version",
     "installed_version": "Installed version",
@@ -743,9 +743,9 @@
       "accept": "Accept",
       "deactivate_account": "Deactivate account",
       "your_own": "You cannot deactivate your own account",
-      "remove_admin_access": "Remove admin access",
-      "cannot_remove": "You cannot remove yourself from administrator",
-      "give_admin_access": "Give admin access",
+      "revoke_admin_access": "Revoke admin access",
+      "cannot_revoke": "You cannot revoke yourself from administrator",
+      "grant_admin_access": "Grant admin access",
       "revoke_read_only_access": "Revoke read only access",
       "grant_read_only_access": "Grant read only access",
       "send_invitation_email": "Send invitation email",
@@ -1018,8 +1018,8 @@
     "ADMIN_USERS_PASSWORD_RESET": "Reset user password",
     "ADMIN_USERS_ACTIVATE": "Activate user",
     "ADMIN_USERS_DEACTIVATE": "Deactivate user",
-    "ADMIN_USERS_GIVE_ADMIN": "Give admin access",
-    "ADMIN_USERS_REMOVE_ADMIN": "Remove admin access",
+    "ADMIN_USERS_GRANT_ADMIN": "Grant admin access",
+    "ADMIN_USERS_REVOKE_ADMIN": "Revoke admin access",
     "ADMIN_USERS_GRANT_READ_ONLY": "Grant read only access",
     "ADMIN_USERS_REVOKE_READ_ONLY": "Revoke read only access",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "Resend invitation email",
@@ -1038,8 +1038,8 @@
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
   "toaster": {
-    "give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin",
+    "grant_user_admin": "Succeeded to grant {{username}} admin",
+    "revoke_user_admin": "Succeeded to revoke {{username}} admin",
     "grant_user_read_only": "Succeeded to grant {{username}} read only",
     "revoke_user_read_only": "Succeeded to revoke {{username}} read only",
     "activate_user_success": "Succeeded to activating {{username}}",

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

@@ -168,7 +168,7 @@
     "could_not_creata_path": "Couldn't create path."
   },
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page."
+    "no_pages_under_this_page": "There are no pages under this page."
   },
   "installer": {
     "tab": "Create account",
@@ -445,12 +445,6 @@
     "file_upload_succeeded": "File upload succeeded.",
     "file_upload_failed": "File upload failed.",
     "initialize_successed": "Succeeded to initialize {{target}}",
-    "give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin",
-    "activate_user_success": "Succeeded to activating {{username}}",
-    "deactivate_user_success": "Succeeded to deactivate {{username}}",
-    "remove_user_success": "Succeeded to removing {{username}}",
-    "remove_external_user_success": "Succeeded to remove {{accountId}}",
     "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
     "issue_share_link": "Succeeded to issue new share link",
     "remove_share_link": "Succeeded to remove {{count}} share links",

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

@@ -751,9 +751,9 @@
       "accept": "承認する",
       "deactivate_account": "アカウント停止",
       "your_own": "自分自身のアカウントを停止することはできません",
-      "remove_admin_access": "管理者から外す",
-      "cannot_remove": "自分自身を管理者から外すことはできません",
-      "give_admin_access": "管理者にする",
+      "revoke_admin_access": "管理者から外す",
+      "cannot_revoke": "自分自身を管理者から外すことはできません",
+      "grant_admin_access": "管理者にする",
       "revoke_read_only_access": "閲覧のみアクセス権を外す",
       "grant_read_only_access": "閲覧のみアクセス権を付与する",
       "send_invitation_email": "招待メールの送信",
@@ -1026,8 +1026,8 @@
     "ADMIN_USERS_PASSWORD_RESET": "ユーザーのパスワードをリセット",
     "ADMIN_USERS_ACTIVATE": "ユーザーを承認する",
     "ADMIN_USERS_DEACTIVATE": "ユーザーを停止する",
-    "ADMIN_USERS_GIVE_ADMIN": "管理者にする",
-    "ADMIN_USERS_REMOVE_ADMIN": "管理者から外す",
+    "ADMIN_USERS_GRANT_ADMIN": "管理者にする",
+    "ADMIN_USERS_REVOKE_ADMIN": "管理者から外す",
     "ADMIN_USERS_GRANT_READ_ONLY": "閲覧のみアクセス権を付与する",
     "ADMIN_USERS_REVOKE_READ_ONLY": "閲覧のみアクセス権を外す",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "招待メールの再送信",
@@ -1046,8 +1046,8 @@
     "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
   },
   "toaster": {
-    "give_user_admin": "{{username}}を管理者に設定しました",
-    "remove_user_admin": "{{username}}を管理者から外しました",
+    "grant_user_admin": "{{username}}を管理者に設定しました",
+    "revoke_user_admin": "{{username}}を管理者から外しました",
     "grant_user_read_only": "{{username}}に閲覧のみアクセス権を付与しました",
     "revoke_user_read_only": "{{username}}から閲覧のみアクセス権を外しました",
     "activate_user_success": "{{username}}を有効化しました",

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

@@ -169,7 +169,7 @@
     "could_not_creata_path": "パスを作成できませんでした。"
   },
   "custom_navigation": {
-    "no_page_list": "このページの配下にはページが存在しません。"
+    "no_pages_under_this_page": "このページの配下にはページが存在しません。"
   },
   "installer": {
     "tab": "アカウント作成",
@@ -478,12 +478,6 @@
     "file_upload_succeeded": "ファイルをアップロードしました",
     "file_upload_failed": "ファイルのアップロードに失敗しました",
     "initialize_successed": "{{target}}を初期化しました",
-    "give_user_admin": "{{username}}を管理者に設定しました",
-    "remove_user_admin": "{{username}}を管理者から外しました",
-    "activate_user_success": "{{username}}を有効化しました",
-    "deactivate_user_success": "{{username}}を無効化しました",
-    "remove_user_success": "{{username}}を削除しました",
-    "remove_external_user_success": "{{accountId}}を削除しました",
     "remove_share_link_success": "{{shareLinkId}}を削除しました",
     "issue_share_link": "共有リンクを作成しました",
     "remove_share_link": "共有リンクを{{count}}件削除しました",

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

@@ -751,9 +751,9 @@
       "accept": "接受",
       "deactivate_account": "停用帐户",
       "your_own": "您不能停用自己的帐户",
-      "remove_admin_access": "删除管理员访问权限",
-      "cannot_remove": "您不能从管理员中删除自己",
-      "give_admin_access": "授予管理员访问权限",
+      "revoke_admin_access": "删除管理员访问权限",
+      "cannot_revoke": "您不能从管理员中删除自己",
+      "grant_admin_access": "授予管理员访问权限",
       "revoke_read_only_access": "取消只读访问",
       "grant_read_only_access": "给予只读权限",
       "send_invitation_email": "发送邀请邮件",
@@ -1026,8 +1026,8 @@
     "ADMIN_USERS_PASSWORD_RESET": "重置用户密码",
     "ADMIN_USERS_ACTIVATE": "激活用户",
     "ADMIN_USERS_DEACTIVATE": "停用用户",
-    "ADMIN_USERS_GIVE_ADMIN": "授予管理员访问权限",
-    "ADMIN_USERS_REMOVE_ADMIN": "删除管理员访问权限",
+    "ADMIN_USERS_GRANT_ADMIN": "授予管理员访问权限",
+    "ADMIN_USERS_REVOKE_ADMIN": "删除管理员访问权限",
     "ADMIN_USERS_GRANT_READ_ONLY": "给予只读权限",
     "ADMIN_USERS_REVOKE_READ_ONLY": "取消只读访问",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "重发邀请函",
@@ -1046,8 +1046,8 @@
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
   "toaster": {
-    "give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin",
+    "grant_user_admin": "Succeeded to grant {{username}} admin",
+    "revoke_user_admin": "Succeeded to revoke {{username}} admin",
     "grant_user_read_only": "Succeeded to grant {{username}} read only",
     "revoke_user_read_only": "Succeeded to revoke {{username}} read only",
 		"activate_user_success": "Succeeded to activating {{username}}",

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

@@ -175,7 +175,7 @@
     "could_not_creata_path": "无法创建路径"
   },
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page."
+    "no_pages_under_this_page": "There are no pages under this page."
   },
 	"installer": {
     "tab": "创建账户",
@@ -434,12 +434,6 @@
     "file_upload_succeeded": "文件上传成功",
     "file_upload_failed": "文件上传失败",
     "initialize_successed": "Succeeded to initialize {{target}}",
-		"give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin ",
-		"activate_user_success": "Succeeded to activating {{username}}",
-		"deactivate_user_success": "Succeeded to deactivate {{username}}",
-		"remove_user_success": "Succeeded to removing {{username}} ",
-    "remove_external_user_success": "Succeeded to remove {{accountId}} ",
     "switch_disable_link_sharing_success": "成功更新分享链接设置",
     "failed_to_reset_password":"Failed to reset password",
     "save_succeeded": "已成功保存",

+ 6 - 6
apps/app/src/client/services/AdminUsersContainer.js

@@ -205,26 +205,26 @@ export default class AdminUsersContainer extends Container {
   }
 
   /**
-   * Give user admin
+   * Grant user admin
    * @memberOf AdminUsersContainer
    * @param {string} userId
    * @return {string} username
    */
-  async giveUserAdmin(userId) {
-    const response = await apiv3Put(`/users/${userId}/giveAdmin`);
+  async grantUserAdmin(userId) {
+    const response = await apiv3Put(`/users/${userId}/grant-admin`);
     const { username } = response.data.userData;
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
   }
 
   /**
-   * Remove user admin
+   * Revoke user admin
    * @memberOf AdminUsersContainer
    * @param {string} userId
    * @return {string} username
    */
-  async removeUserAdmin(userId) {
-    const response = await apiv3Put(`/users/${userId}/removeAdmin`);
+  async revokeUserAdmin(userId) {
+    const response = await apiv3Put(`/users/${userId}/revoke-admin`);
     const { username } = response.data.userData;
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;

+ 4 - 2
apps/app/src/client/util/bookmark-utils.ts

@@ -41,6 +41,8 @@ export const toggleBookmark = async(pageId: string, status: boolean): Promise<vo
 };
 
 // Update Bookmark folder
-export const updateBookmarkFolder = async(bookmarkFolderId: string, name: string, parent: string | null): Promise<void> => {
-  await apiv3Put('/bookmark-folder', { bookmarkFolderId, name, parent });
+export const updateBookmarkFolder = async(bookmarkFolderId: string, name: string, parent: string | null, children: BookmarkFolderItems[]): Promise<void> => {
+  await apiv3Put('/bookmark-folder', {
+    bookmarkFolderId, name, parent, children,
+  });
 };

+ 9 - 9
apps/app/src/components/Admin/Users/GiveAdminButton.tsx → apps/app/src/components/Admin/Users/GrantAdminButton.tsx

@@ -8,20 +8,20 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-type GiveAdminButtonProps = {
+type GrantAdminButtonProps = {
   adminUsersContainer: AdminUsersContainer,
   user: IUserHasId,
 }
 
-const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
+const GrantAdminButton = (props: GrantAdminButtonProps): JSX.Element => {
 
   const { t } = useTranslation('admin');
   const { adminUsersContainer, user } = props;
 
-  const onClickGiveAdminBtnHandler = useCallback(async() => {
+  const onClickGrantAdminBtnHandler = useCallback(async() => {
     try {
-      const username = await adminUsersContainer.giveUserAdmin(user._id);
-      toastSuccess(t('toaster.give_user_admin', { username }));
+      const username = await adminUsersContainer.grantUserAdmin(user._id);
+      toastSuccess(t('toaster.grant_user_admin', { username }));
     }
     catch (err) {
       toastError(err);
@@ -29,8 +29,8 @@ const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
   }, [adminUsersContainer, t, user._id]);
 
   return (
-    <button className="dropdown-item" type="button" onClick={() => onClickGiveAdminBtnHandler()}>
-      <i className="icon-fw icon-user-following"></i> {t('user_management.user_table.give_admin_access')}
+    <button className="dropdown-item" type="button" onClick={() => onClickGrantAdminBtnHandler()}>
+      <i className="icon-fw icon-user-following"></i> {t('user_management.user_table.grant_admin_access')}
     </button>
   );
 
@@ -40,6 +40,6 @@ const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
  * Wrapper component for using unstated
  */
 // eslint-disable-next-line max-len
-const GiveAdminButtonWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(GiveAdminButton, [AdminUsersContainer]);
+const GrantAdminButtonWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(GrantAdminButton, [AdminUsersContainer]);
 
-export default GiveAdminButtonWrapper;
+export default GrantAdminButtonWrapper;

+ 15 - 15
apps/app/src/components/Admin/Users/RemoveAdminButton.tsx → apps/app/src/components/Admin/Users/RevokeAdminButton.tsx

@@ -9,40 +9,40 @@ import { useCurrentUser } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-type RemoveAdminButtonProps = {
+type RevokeAdminButtonProps = {
   adminUsersContainer: AdminUsersContainer,
   user: IUserHasId,
 }
 
-const RemoveAdminButton = (props: RemoveAdminButtonProps): JSX.Element => {
+const RevokeAdminButton = (props: RevokeAdminButtonProps): JSX.Element => {
 
   const { t } = useTranslation('admin');
   const { data: currentUser } = useCurrentUser();
   const { adminUsersContainer, user } = props;
 
-  const onClickRemoveAdminBtnHandler = useCallback(async() => {
+  const onClickRevokeAdminBtnHandler = useCallback(async() => {
     try {
-      const username = await adminUsersContainer.removeUserAdmin(user._id);
-      toastSuccess(t('toaster.remove_user_admin', { username }));
+      const username = await adminUsersContainer.revokeUserAdmin(user._id);
+      toastSuccess(t('toaster.revoke_user_admin', { username }));
     }
     catch (err) {
       toastError(err);
     }
   }, [adminUsersContainer, t, user._id]);
 
-  const renderRemoveAdminBtn = () => {
+  const renderRevokeAdminBtn = () => {
     return (
-      <button className="dropdown-item" type="button" onClick={() => onClickRemoveAdminBtnHandler()}>
-        <i className="icon-fw icon-user-unfollow"></i>{t('user_management.user_table.remove_admin_access')}
+      <button className="dropdown-item" type="button" onClick={() => onClickRevokeAdminBtnHandler()}>
+        <i className="icon-fw icon-user-unfollow"></i>{t('user_management.user_table.revoke_admin_access')}
       </button>
     );
   };
 
-  const renderRemoveAdminAlert = () => {
+  const renderRevokeAdminAlert = () => {
     return (
       <div className="px-4">
-        <i className="icon-fw icon-user-unfollow mb-2"></i>{t('user_management.user_table.remove_admin_access')}
-        <p className="alert alert-danger">{t('user_management.user_table.cannot_remove')}</p>
+        <i className="icon-fw icon-user-unfollow mb-2"></i>{t('user_management.user_table.revoke_admin_access')}
+        <p className="alert alert-danger">{t('user_management.user_table.cannot_revoke')}</p>
       </div>
     );
   };
@@ -53,8 +53,8 @@ const RemoveAdminButton = (props: RemoveAdminButtonProps): JSX.Element => {
 
   return (
     <>
-      {user.username !== currentUser.username ? renderRemoveAdminBtn()
-        : renderRemoveAdminAlert()}
+      {user.username !== currentUser.username ? renderRevokeAdminBtn()
+        : renderRevokeAdminAlert()}
     </>
   );
 };
@@ -62,6 +62,6 @@ const RemoveAdminButton = (props: RemoveAdminButtonProps): JSX.Element => {
 /**
 * Wrapper component for using unstated
 */
-const RemoveAdminButtonWrapper = withUnstatedContainers(RemoveAdminButton, [AdminUsersContainer]);
+const RevokeAdminButtonWrapper = withUnstatedContainers(RevokeAdminButton, [AdminUsersContainer]);
 
-export default RemoveAdminButtonWrapper;
+export default RevokeAdminButtonWrapper;

+ 13 - 13
apps/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx → apps/app/src/components/Admin/Users/RevokeAdminMenuItem.tsx

@@ -10,17 +10,17 @@ import { useCurrentUser } from '~/stores/context';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-const RemoveAdminAlert = React.memo((): JSX.Element => {
+const RevokeAdminAlert = React.memo((): JSX.Element => {
   const { t } = useTranslation();
 
   return (
     <div className="px-4">
-      <i className="icon-fw icon-user-unfollow mb-2"></i>{t('admin:user_management.user_table.remove_admin_access')}
-      <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_remove')}</p>
+      <i className="icon-fw icon-user-unfollow mb-2"></i>{t('admin:user_management.user_table.revoke_admin_access')}
+      <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_revoke')}</p>
     </div>
   );
 });
-RemoveAdminAlert.displayName = 'RemoveAdminAlert';
+RevokeAdminAlert.displayName = 'RevokeAdminAlert';
 
 
 type Props = {
@@ -28,17 +28,17 @@ type Props = {
   user: IUserHasId,
 }
 
-const RemoveAdminMenuItem = (props: Props): JSX.Element => {
+const RevokeAdminMenuItem = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
 
   const { adminUsersContainer, user } = props;
 
   const { data: currentUser } = useCurrentUser();
 
-  const clickRemoveAdminBtnHandler = useCallback(async() => {
+  const clickRevokeAdminBtnHandler = useCallback(async() => {
     try {
-      const username = await adminUsersContainer.removeUserAdmin(user._id);
-      toastSuccess(t('toaster.remove_user_admin', { username }));
+      const username = await adminUsersContainer.revokeUserAdmin(user._id);
+      toastSuccess(t('toaster.revoke_user_admin', { username }));
     }
     catch (err) {
       toastError(err);
@@ -48,17 +48,17 @@ const RemoveAdminMenuItem = (props: Props): JSX.Element => {
 
   return user.username !== currentUser?.username
     ? (
-      <button className="dropdown-item" type="button" onClick={clickRemoveAdminBtnHandler}>
-        <i className="icon-fw icon-user-unfollow"></i> {t('user_management.user_table.remove_admin_access')}
+      <button className="dropdown-item" type="button" onClick={clickRevokeAdminBtnHandler}>
+        <i className="icon-fw icon-user-unfollow"></i> {t('user_management.user_table.revoke_admin_access')}
       </button>
     )
-    : <RemoveAdminAlert />;
+    : <RevokeAdminAlert />;
 };
 
 /**
 * Wrapper component for using unstated
 */
 // eslint-disable-next-line max-len
-const RemoveAdminMenuItemWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(RemoveAdminMenuItem, [AdminUsersContainer]);
+const RevokeAdminMenuItemWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(RevokeAdminMenuItem, [AdminUsersContainer]);
 
-export default RemoveAdminMenuItemWrapper;
+export default RevokeAdminMenuItemWrapper;

+ 3 - 3
apps/app/src/components/Admin/Users/UserMenu.tsx

@@ -10,9 +10,9 @@ import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-import GiveAdminButton from './GiveAdminButton';
+import GrantAdminButton from './GrantAdminButton';
 import GrantReadOnlyButton from './GrantReadOnlyButton';
-import RemoveAdminMenuItem from './RemoveAdminMenuItem';
+import RevokeAdminMenuItem from './RevokeAdminMenuItem';
 import RevokeReadOnlyMenuItem from './RevokeReadOnlyMenuItem';
 import SendInvitationEmailButton from './SendInvitationEmailButton';
 import StatusActivateButton from './StatusActivateButton';
@@ -83,7 +83,7 @@ const UserMenu = (props: UserMenuProps) => {
         <li className="dropdown-divider pl-0"></li>
         <li className="dropdown-header">{t('user_management.user_table.administrator_menu')}</li>
         <li>
-          {user.admin ? <RemoveAdminMenuItem user={user} /> : <GiveAdminButton user={user} />}
+          {user.admin ? <RevokeAdminMenuItem user={user} /> : <GrantAdminButton user={user} />}
         </li>
         <li>
           {user.readOnly ? <RevokeReadOnlyMenuItem user={user} /> : <GrantReadOnlyButton user={user} />}

+ 40 - 34
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -26,6 +26,7 @@ type BookmarkFolderItemProps = {
   isReadOnlyUser: boolean
   bookmarkFolder: BookmarkFolderItems
   isOpen?: boolean
+  isOperable: boolean,
   level: number
   root: string
   isUserHomePage?: boolean
@@ -37,7 +38,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const BASE_FOLDER_PADDING = 15;
   const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const {
-    isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, level, root, isUserHomePage,
+    isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, isOperable, level, root, isUserHomePage,
     onClickDeleteBookmarkHandler, bookmarkFolderTreeMutation,
   } = props;
 
@@ -64,14 +65,15 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   // Rename for bookmark folder handler
   const onPressEnterHandlerForRename = useCallback(async(folderName: string) => {
     try {
-      await updateBookmarkFolder(folderId, folderName, parent);
+      // TODO: do not use any type
+      await updateBookmarkFolder(folderId, folderName, parent as any, children);
       bookmarkFolderTreeMutation();
       setIsRenameAction(false);
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolderTreeMutation, folderId, parent]);
+  }, [bookmarkFolderTreeMutation, children, folderId, parent]);
 
   // Create new folder / subfolder handler
   const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
@@ -98,7 +100,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     if (dragItemType === DRAG_ITEM_TYPE.FOLDER) {
       try {
         if (item.bookmarkFolder != null) {
-          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id);
+          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id, item.bookmarkFolder.children);
           bookmarkFolderTreeMutation();
         }
       }
@@ -148,6 +150,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
           <BookmarkFolderItem
             key={childFolder._id}
             isReadOnlyUser={isReadOnlyUser}
+            isOperable={props.isOperable}
             bookmarkFolder={childFolder}
             level={level + 1}
             root={root}
@@ -166,6 +169,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         <BookmarkItem
           key={bookmark._id}
           isReadOnlyUser={isReadOnlyUser}
+          isOperable={props.isOperable}
           bookmarkedPage={bookmark.page}
           level={level + 1}
           parentFolder={bookmarkFolder}
@@ -197,13 +201,13 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
 
   const onClickMoveToRootHandlerForBookmarkFolderItemControl = useCallback(async() => {
     try {
-      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null);
+      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null, bookmarkFolder.children);
       bookmarkFolderTreeMutation();
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolder._id, bookmarkFolder.name, bookmarkFolderTreeMutation]);
+  }, [bookmarkFolder._id, bookmarkFolder.children, bookmarkFolder.name, bookmarkFolderTreeMutation]);
 
   return (
     <div id={`grw-bookmark-folder-item-${folderId}`} className="grw-foldertree-item-container">
@@ -211,8 +215,8 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         key={folderId}
         type={acceptedTypes}
         item={props}
-        useDragMode={true}
-        useDropMode={true}
+        useDragMode={isOperable}
+        useDropMode={isOperable}
         onDropItem={itemDropHandler}
         isDropable={isDropable}
       >
@@ -252,33 +256,35 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
               </div>
             </>
           )}
-          <div className="grw-foldertree-control d-flex">
-            <BookmarkFolderItemControl
-              onClickRename={onClickRenameHandler}
-              onClickDelete={onClickDeleteHandler}
-              onClickMoveToRoot={bookmarkFolder.parent != null
-                ? onClickMoveToRootHandlerForBookmarkFolderItemControl
-                : undefined
-              }
-            >
-              <div onClick={e => e.stopPropagation()}>
-                <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
-                  <i className="icon-options fa fa-rotate-90 p-1"></i>
-                </DropdownToggle>
-              </div>
-            </BookmarkFolderItemControl>
-            {/* Maximum folder hierarchy of 2 levels */}
-            {!(bookmarkFolder.parent != null) && (
-              <button
-                id='create-bookmark-folder-button'
-                type="button"
-                className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
-                onClick={onClickPlusButton}
+          { isOperable && (
+            <div className="grw-foldertree-control d-flex">
+              <BookmarkFolderItemControl
+                onClickRename={onClickRenameHandler}
+                onClickDelete={onClickDeleteHandler}
+                onClickMoveToRoot={bookmarkFolder.parent != null
+                  ? onClickMoveToRootHandlerForBookmarkFolderItemControl
+                  : undefined
+                }
               >
-                <i className="icon-plus d-block p-0" />
-              </button>
-            )}
-          </div>
+                <div onClick={e => e.stopPropagation()}>
+                  <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
+                    <i className="icon-options fa fa-rotate-90 p-1"></i>
+                  </DropdownToggle>
+                </div>
+              </BookmarkFolderItemControl>
+              {/* Maximum folder hierarchy of 2 levels */}
+              {!(bookmarkFolder.parent != null) && (
+                <button
+                  id='create-bookmark-folder-button'
+                  type="button"
+                  className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
+                  onClick={onClickPlusButton}
+                >
+                  <i className="icon-plus d-block p-0" />
+                </button>
+              )}
+            </div>
+          )}
         </li>
       </DragAndDropWrapper>
       {isCreateAction && (

+ 7 - 7
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -6,8 +6,9 @@ import { DropdownItem, DropdownMenu, UncontrolledDropdown } from 'reactstrap';
 
 import { addBookmarkToFolder, toggleBookmark } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
-import { useSWRBookmarkInfo, useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
+import { useSWRBookmarkInfo, useSWRxUserBookmarks } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
+import { useCurrentUser } from '~/stores/context';
 import { useSWRxPageInfo } from '~/stores/page';
 
 import { BookmarkFolderMenuItem } from './BookmarkFolderMenuItem';
@@ -18,9 +19,11 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode, pageId: s
   const [selectedItem, setSelectedItem] = useState<string | null>(null);
   const [isOpen, setIsOpen] = useState(false);
 
-  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild();
+  const { data: currentUser } = useCurrentUser();
+  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
   const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
-  const { mutate: mutateUserBookmarks } = useSWRxCurrentUserBookmarks();
+
+  const { mutate: mutateUserBookmarks } = useSWRxUserBookmarks(currentUser?._id);
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
 
   const isBookmarked = bookmarkInfo?.isBookmarked ?? false;
@@ -87,9 +90,6 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode, pageId: s
     setSelectedItem(itemId);
 
     try {
-      if (isBookmarked) {
-        await toggleBookmarkHandler();
-      }
       if (pageId != null) {
         await addBookmarkToFolder(pageId, itemId === 'root' ? null : itemId);
       }
@@ -100,7 +100,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode, pageId: s
     catch (err) {
       toastError(err);
     }
-  }, [mutateBookmarkFolders, isBookmarked, pageId, mutateBookmarkInfo, mutateUserBookmarks, toggleBookmarkHandler]);
+  }, [mutateBookmarkFolders, pageId, mutateBookmarkInfo, mutateUserBookmarks]);
 
   const renderBookmarkMenuItem = () => {
     return (

+ 14 - 4
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -6,7 +6,7 @@ import { useTranslation } from 'next-i18next';
 import { toastSuccess } from '~/client/util/toastr';
 import { IPageToDeleteWithMeta } from '~/interfaces/page';
 import { OnDeletedFunction } from '~/interfaces/ui';
-import { useSWRxCurrentUserBookmarks, useSWRBookmarkInfo } from '~/stores/bookmark';
+import { useSWRxUserBookmarks, useSWRBookmarkInfo } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { useIsReadOnlyUser } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
@@ -23,15 +23,23 @@ import styles from './BookmarkFolderTree.module.scss';
 //   parentFolder: BookmarkFolderItems | null
 //  } & IPageHasId
 
-export const BookmarkFolderTree: React.FC<{isUserHomePage?: boolean}> = ({ isUserHomePage }) => {
+type Props = {
+  isUserHomePage?: boolean,
+  userId?: string,
+  isOperable: boolean,
+}
+
+export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
+  const { isUserHomePage, userId } = props;
+
   // const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const { t } = useTranslation();
 
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPage } = useSWRxCurrentPage();
   const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
-  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild();
-  const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxCurrentUserBookmarks();
+  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(userId);
+  const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxUserBookmarks(userId);
   const { open: openDeleteModal } = usePageDeleteModal();
 
   const bookmarkFolderTreeMutation = useCallback(() => {
@@ -93,6 +101,7 @@ export const BookmarkFolderTree: React.FC<{isUserHomePage?: boolean}> = ({ isUse
             <BookmarkFolderItem
               key={bookmarkFolder._id}
               isReadOnlyUser={!!isReadOnlyUser}
+              isOperable={props.isOperable}
               bookmarkFolder={bookmarkFolder}
               isOpen={false}
               level={0}
@@ -108,6 +117,7 @@ export const BookmarkFolderTree: React.FC<{isUserHomePage?: boolean}> = ({ isUse
             <BookmarkItem
               key={userBookmark._id}
               isReadOnlyUser={!!isReadOnlyUser}
+              isOperable={props.isOperable}
               bookmarkedPage={userBookmark}
               level={0}
               parentFolder={null}

+ 5 - 2
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -23,6 +23,7 @@ import { DragAndDropWrapper } from './DragAndDropWrapper';
 
 type Props = {
   isReadOnlyUser: boolean
+  isOperable: boolean,
   bookmarkedPage: IPageHasId,
   level: number,
   parentFolder: BookmarkFolderItems | null,
@@ -38,7 +39,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    isReadOnlyUser, bookmarkedPage, onClickDeleteBookmarkHandler,
+    isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteBookmarkHandler,
     parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
   } = props;
 
@@ -113,7 +114,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     <DragAndDropWrapper
       item={dragItem}
       type={[DRAG_ITEM_TYPE.BOOKMARK]}
-      useDragMode={true}
+      useDragMode={isOperable}
     >
       <li
         className="grw-bookmark-item-list list-group-item list-group-item-action border-0 py-0 mr-auto d-flex align-items-center"
@@ -130,6 +131,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             validationTarget={ValidationTarget.PAGE}
           />
         ) : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle}/>}
+
         <div className='grw-foldertree-control'>
           <PageItemControl
             pageId={bookmarkedPage._id}
@@ -149,6 +151,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             </DropdownToggle>
           </PageItemControl>
         </div>
+
         <UncontrolledTooltip
           modifiers={{ preventOverflow: { boundariesElement: 'window' } }}
           autohide={false}

+ 1 - 1
apps/app/src/components/PageList/PageList.tsx

@@ -51,7 +51,7 @@ const PageList = (props: Props<IPageInfoForEntity>): JSX.Element => {
   if (pageList.length === 0) {
     return (
       <div className="mt-2">
-        <p>{t('custom_navigation.no_page_list')}</p>
+        <p>{t('custom_navigation.no_pages_under_this_page')}</p>
       </div>
     );
   }

+ 3 - 3
apps/app/src/components/PageList/PageListItemL.tsx

@@ -24,7 +24,7 @@ import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 import LinkedPagePath from '~/models/linked-page-path';
-import { useSWRBookmarkInfo, useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
+import { useSWRBookmarkInfo, useSWRxUserBookmarks } from '~/stores/bookmark';
 import {
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
 } from '~/stores/modal';
@@ -90,7 +90,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
   const shouldFetch = isSelected && (pageData != null || pageMeta != null);
   const { data: pageInfo } = useSWRxPageInfo(shouldFetch ? pageData?._id : null);
-  const { mutate: mutateCurrentUserBookmark } = useSWRxCurrentUserBookmarks();
+  const { mutate: mutateUserBookmark } = useSWRxUserBookmarks();
   const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageData?._id);
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
@@ -128,7 +128,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
     await bookmarkOperation(_pageId);
-    mutateCurrentUserBookmark();
+    mutateUserBookmark();
     mutateBookmarkInfo();
   };
 

+ 2 - 2
apps/app/src/components/PageRenameModal.tsx

@@ -255,7 +255,7 @@ const PageRenameModal = (): JSX.Element => {
             <div className="custom-control custom-radio custom-radio-warning">
               <input
                 className="custom-control-input"
-                name="recursively"
+                name="withoutExistRecursively"
                 id="cbRenameThisPageOnly"
                 type="radio"
                 checked={!isRenameRecursively}
@@ -268,7 +268,7 @@ const PageRenameModal = (): JSX.Element => {
             <div className="custom-control custom-radio custom-radio-warning mt-1">
               <input
                 className="custom-control-input"
-                name="withoutExistRecursively"
+                name="recursively"
                 id="cbForceRenameRecursively"
                 type="radio"
                 checked={isRenameRecursively}

+ 21 - 31
apps/app/src/components/PageTimeline.tsx

@@ -1,15 +1,15 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 
-import { apiv3Get } from '~/client/util/apiv3-client';
 import { IPageHasId } from '~/interfaces/page';
 import { useCurrentPagePath } from '~/stores/page';
+import { useSWRINFxPageTimeline } from '~/stores/page-timeline';
 import { useTimelineOptions } from '~/stores/renderer';
 
+import InfiniteScroll from './InfiniteScroll';
 import { RevisionLoader } from './Page/RevisionLoader';
-import PaginationWrapper from './PaginationWrapper';
 
 import styles from './PageTimeline.module.scss';
 
@@ -42,48 +42,38 @@ const TimelineCard = ({ page }: TimelineCardProps): JSX.Element => {
   );
 };
 
-
 export const PageTimeline = (): JSX.Element => {
-  const [activePage, setActivePage] = useState(1);
-  const [totalPageItems, setTotalPageItems] = useState(0);
-  const [limit, setLimit] = useState(10);
-  const [pages, setPages] = useState<IPageHasId[] | null>(null);
 
-  const { data: currentPagePath } = useCurrentPagePath();
+  const PER_PAGE = 3;
   const { t } = useTranslation();
+  const { data: currentPagePath } = useCurrentPagePath();
 
-  const handlePage = useCallback(async(selectedPage: number) => {
-    if (currentPagePath == null) { return }
-    const res = await apiv3Get('/pages/list', { path: currentPagePath, page: selectedPage });
-    setTotalPageItems(res.data.totalCount);
-    setPages(res.data.pages);
-    setLimit(res.data.limit);
-    setActivePage(selectedPage);
-  }, [currentPagePath]);
+  const swrInfinitexPageTimeline = useSWRINFxPageTimeline(currentPagePath, PER_PAGE);
+  const { data } = swrInfinitexPageTimeline;
 
-  useEffect(() => {
-    handlePage(1);
-  }, [handlePage]);
+  const isEmpty = data?.[0]?.pages.length === 0;
+  const isReachingEnd = isEmpty || (data != null && data[data.length - 1]?.pages.length < PER_PAGE);
 
-  if (pages == null || pages.length === 0) {
+  if (data == null || isEmpty) {
     return (
       <div className="mt-2">
-        {/* eslint-disable-next-line react/no-danger */}
-        <p>{t('custom_navigation.no_page_list')}</p>
+        <p>{t('custom_navigation.no_pages_under_this_page')}</p>
       </div>
     );
   }
 
   return (
     <div>
-      { pages.map(page => <TimelineCard key={page._id} page={page} />) }
-      <PaginationWrapper
-        activePage={activePage}
-        changePage={handlePage}
-        totalItemsCount={totalPageItems}
-        pagingLimit={limit}
-        align="center"
-      />
+      <InfiniteScroll
+        swrInifiniteResponse={swrInfinitexPageTimeline}
+        isReachingEnd={isReachingEnd}
+      >
+        { data != null && data.flatMap(apiResult => apiResult.pages)
+          .map(page => (
+            <TimelineCard key={page._id} page={page} />
+          ))
+        }
+      </InfiniteScroll>
     </div>
   );
 };

+ 5 - 2
apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx

@@ -8,12 +8,15 @@ import { BookmarkFolderNameInput } from '~/components/Bookmarks/BookmarkFolderNa
 import { BookmarkFolderTree } from '~/components/Bookmarks/BookmarkFolderTree';
 import { FolderPlusIcon } from '~/components/Icons/FolderPlusIcon';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
+import { useCurrentUser } from '~/stores/context';
 
 export const BookmarkContents = (): JSX.Element => {
 
   const { t } = useTranslation();
   const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
-  const { mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild();
+
+  const { data: currentUser } = useCurrentUser();
+  const { mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
 
   const onClickNewBookmarkFolder = useCallback(() => {
     setIsCreateAction(true);
@@ -53,7 +56,7 @@ export const BookmarkContents = (): JSX.Element => {
           />
         </div>
       )}
-      <BookmarkFolderTree />
+      <BookmarkFolderTree isOperable userId={currentUser?._id} />
     </>
   );
 };

+ 3 - 3
apps/app/src/components/Sidebar/PageTree/Item.tsx

@@ -22,7 +22,7 @@ import { NotAvailableForReadOnlyUser } from '~/components/NotAvailableForReadOnl
 import {
   IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
 } from '~/interfaces/page';
-import { useSWRBookmarkInfo, useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
+import { useSWRBookmarkInfo, useSWRxUserBookmarks } from '~/stores/bookmark';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
@@ -124,7 +124,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const [isCreating, setCreating] = useState(false);
 
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
-  const { mutate: mutateCurrentUserBookmarks } = useSWRxCurrentUserBookmarks();
+  const { mutate: mutateUserBookmarks } = useSWRxUserBookmarks();
   const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(page._id);
 
   // descendantCount
@@ -261,7 +261,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
     await bookmarkOperation(_pageId);
-    mutateCurrentUserBookmarks();
+    mutateUserBookmarks();
     mutateBookmarkInfo();
   };
 

+ 5 - 1
apps/app/src/components/UsersHomePageFooter.tsx

@@ -2,9 +2,11 @@ import React, { useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
+
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
 import { RecentCreated } from '~/components/RecentCreated/RecentCreated';
 import styles from '~/components/UsersHomePageFooter.module.scss';
+import { useCurrentUser } from '~/stores/context';
 
 import { BookmarkFolderTree } from './Bookmarks/BookmarkFolderTree';
 import { CompressIcon } from './Icons/CompressIcon';
@@ -18,6 +20,8 @@ export const UsersHomePageFooter = (props: UsersHomePageFooterProps): JSX.Elemen
   const { t } = useTranslation();
   const { creatorId } = props;
   const [isExpanded, setIsExpanded] = useState<boolean>(false);
+  const { data: currentUser } = useCurrentUser();
+  const isOperable = currentUser?._id === creatorId;
 
   return (
     <div className={`container-lg user-page-footer py-5 ${styles['user-page-footer']}`}>
@@ -39,7 +43,7 @@ export const UsersHomePageFooter = (props: UsersHomePageFooterProps): JSX.Elemen
         </h2>
         {/* TODO: In bookmark folders v1, the button to create a new folder does not exist. The button should be included in the bookmark component. */}
         <div className={`${isExpanded ? `${styles['grw-bookarks-contents-expanded']}` : `${styles['grw-bookarks-contents-compressed']}`}`}>
-          <BookmarkFolderTree isUserHomePage={true} />
+          <BookmarkFolderTree isUserHomePage={true} isOperable={isOperable} userId={creatorId} />
         </div>
       </div>
       <div className="grw-user-page-list-m mt-5 d-edit-none">

+ 6 - 6
apps/app/src/interfaces/activity.ts

@@ -149,8 +149,8 @@ const ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE = 'ADMIN_SLACK_CONFIGURATI
 const ACTION_ADMIN_USERS_INVITE = 'ADMIN_USERS_INVITE';
 const ACTION_ADMIN_USERS_PASSWORD_RESET = 'ADMIN_USERS_PASSWORD_RESET';
 const ACTION_ADMIN_USERS_ACTIVATE = 'ADMIN_USERS_ACTIVATE';
-const ACTION_ADMIN_USERS_GIVE_ADMIN = 'ADMIN_USERS_GIVE_ADMIN';
-const ACTION_ADMIN_USERS_REMOVE_ADMIN = 'ADMIN_USERS_REMOVE_ADMIN';
+const ACTION_ADMIN_USERS_GRANT_ADMIN = 'ADMIN_USERS_GRANT_ADMIN';
+const ACTION_ADMIN_USERS_REVOKE_ADMIN = 'ADMIN_USERS_REVOKE_ADMIN';
 const ACTION_ADMIN_USERS_GRANT_READ_ONLY = 'ADMIN_USERS_GRANT_READ_ONLY';
 const ACTION_ADMIN_USERS_REVOKE_READ_ONLY = 'ADMIN_USERS_REVOKE_READ_ONLY';
 const ACTION_ADMIN_USERS_DEACTIVATE = 'ADMIN_USERS_DEACTIVATE';
@@ -329,8 +329,8 @@ export const SupportedAction = {
   ACTION_ADMIN_USERS_PASSWORD_RESET,
   ACTION_ADMIN_USERS_ACTIVATE,
   ACTION_ADMIN_USERS_DEACTIVATE,
-  ACTION_ADMIN_USERS_GIVE_ADMIN,
-  ACTION_ADMIN_USERS_REMOVE_ADMIN,
+  ACTION_ADMIN_USERS_GRANT_ADMIN,
+  ACTION_ADMIN_USERS_REVOKE_ADMIN,
   ACTION_ADMIN_USERS_GRANT_READ_ONLY,
   ACTION_ADMIN_USERS_REVOKE_READ_ONLY,
   ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL,
@@ -516,8 +516,8 @@ export const LargeActionGroup = {
   ACTION_ADMIN_USERS_PASSWORD_RESET,
   ACTION_ADMIN_USERS_ACTIVATE,
   ACTION_ADMIN_USERS_DEACTIVATE,
-  ACTION_ADMIN_USERS_GIVE_ADMIN,
-  ACTION_ADMIN_USERS_REMOVE_ADMIN,
+  ACTION_ADMIN_USERS_GRANT_ADMIN,
+  ACTION_ADMIN_USERS_REVOKE_ADMIN,
   ACTION_ADMIN_USERS_GRANT_READ_ONLY,
   ACTION_ADMIN_USERS_REVOKE_READ_ONLY,
   ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL,

+ 10 - 13
apps/app/src/interfaces/bookmark-info.ts

@@ -3,13 +3,13 @@ import { Ref } from '@growi/core';
 import { IPageHasId } from '~/interfaces/page';
 import { IUser } from '~/interfaces/user';
 
-export type IBookmarkInfo = {
+export interface IBookmarkInfo {
   sumOfBookmarks: number;
   isBookmarked: boolean,
   bookmarkedUsers: IUser[]
-};
+}
 
-type BookmarkedPage = {
+export interface BookmarkedPage {
   _id: string,
   page: IPageHasId,
   user: Ref<IUser>,
@@ -24,12 +24,10 @@ export interface IBookmarkFolder {
   parent?: Ref<this>
 }
 
-export interface BookmarkFolderItems {
-  _id: string
-  name: string
-  parent: string
-  children: this[]
-  bookmarks: BookmarkedPage[]
+export interface BookmarkFolderItems extends IBookmarkFolder {
+  _id: string;
+  children: BookmarkFolderItems[];
+  bookmarks: BookmarkedPage[];
 }
 
 export const DRAG_ITEM_TYPE = {
@@ -37,15 +35,14 @@ export const DRAG_ITEM_TYPE = {
   BOOKMARK: 'BOOKMARK',
 } as const;
 
-type BookmarkDragItem = {
+interface BookmarkDragItem {
   bookmarkFolder: BookmarkFolderItems
   level: number
   root: string
 }
 
-export type DragItemDataType = BookmarkDragItem & {
+export interface DragItemDataType extends BookmarkDragItem, IPageHasId {
   parentFolder: BookmarkFolderItems | null
-} & IPageHasId
-
+}
 
 export type DragItemType = typeof DRAG_ITEM_TYPE[keyof typeof DRAG_ITEM_TYPE];

+ 1 - 3
apps/app/src/server/console.js

@@ -4,7 +4,7 @@ const repl = require('repl');
 
 const mongoose = require('mongoose');
 
-const { initMongooseGlobalSettings, getMongoUri, mongoOptions } = require('~/server/util/mongoose-utils');
+const { getMongoUri, mongoOptions } = require('~/server/util/mongoose-utils');
 
 const models = require('./models');
 
@@ -34,8 +34,6 @@ fs.readFile(replHistoryPath, 'utf8', (err, data) => {
 replServer.context.mongoose = mongoose;
 replServer.context.models = models;
 
-initMongooseGlobalSettings();
-
 mongoose.connect(getMongoUri(), mongoOptions)
   .then(() => {
     replServer.context.db = mongoose.connection.db;

+ 3 - 1
apps/app/src/server/crowi/express-init.js

@@ -5,6 +5,8 @@ import qs from 'qs';
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
+import registerSafeRedirectFactory from '../middlewares/safe-redirect';
+
 const logger = loggerFactory('growi:crowi:express-init');
 
 module.exports = function(crowi, app) {
@@ -22,7 +24,7 @@ module.exports = function(crowi, app) {
   const mongoSanitize = require('express-mongo-sanitize');
 
   const promster = require('../middlewares/promster')(crowi, app);
-  const registerSafeRedirect = require('../middlewares/safe-redirect')();
+  const registerSafeRedirect = registerSafeRedirectFactory();
   const injectCurrentuserToLocalvars = require('../middlewares/inject-currentuser-to-localvars')();
   const autoReconnectToS2sMsgServer = require('../middlewares/auto-reconnect-to-s2s-msg-server')(crowi);
 

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

@@ -26,7 +26,7 @@ import UserGroup from '../models/user-group';
 import AclService from '../service/acl';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
-import ConfigManager from '../service/config-manager';
+import { configManager as configManagerSingletonInstance } from '../service/config-manager';
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
@@ -37,7 +37,7 @@ import { PluginService } from '../service/plugin';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
-import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '../util/mongoose-utils';
+import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
 
 
 const logger = loggerFactory('growi:crowi');
@@ -224,8 +224,6 @@ Crowi.prototype.setupDatabase = function() {
   // mongoUri = mongodb://user:password@host/dbname
   const mongoUri = getMongoUri();
 
-  initMongooseGlobalSettings();
-
   return mongoose.connect(mongoUri, mongoOptions);
 };
 
@@ -276,7 +274,7 @@ Crowi.prototype.setupSessionConfig = async function() {
 };
 
 Crowi.prototype.setupConfigManager = async function() {
-  this.configManager = new ConfigManager();
+  this.configManager = configManagerSingletonInstance;
   return this.configManager.loadConfigs();
 };
 

+ 3 - 3
apps/app/test/unit/middlewares/exclude-read-only-user.test.ts → apps/app/src/server/middlewares/exclude-read-only-user.spec.ts

@@ -1,6 +1,6 @@
 import { ErrorV3 } from '@growi/core';
 
-import { excludeReadOnlyUser } from '../../../src/server/middlewares/exclude-read-only-user';
+import { excludeReadOnlyUser } from './exclude-read-only-user';
 
 describe('excludeReadOnlyUser', () => {
   let req;
@@ -12,9 +12,9 @@ describe('excludeReadOnlyUser', () => {
       user: {},
     };
     res = {
-      apiv3Err: jest.fn(),
+      apiv3Err: vi.fn(),
     };
-    next = jest.fn();
+    next = vi.fn();
   });
 
   test('should call next if user is not found', () => {

+ 18 - 30
apps/app/test/unit/middlewares/safe-redirect.test.js → apps/app/src/server/middlewares/safe-redirect.spec.ts

@@ -1,105 +1,93 @@
-/* eslint-disable arrow-body-style */
+import type { Request } from 'express';
 
-describe('safeRedirect', () => {
-  let registerSafeRedirect;
+import registerSafeRedirectFactory, { type ResWithSafeRedirect } from './safe-redirect';
 
+describe('safeRedirect', () => {
   const whitelistOfHosts = [
     'white1.example.com:8080',
     'white2.example.com',
   ];
-
-  beforeEach(async() => {
-    registerSafeRedirect = require('~/server/middlewares/safe-redirect')(whitelistOfHosts);
-  });
+  const registerSafeRedirect = registerSafeRedirectFactory(whitelistOfHosts);
 
   describe('res.safeRedirect', () => {
     // setup req/res/next
+    const getFunc = vi.fn().mockReturnValue('example.com');
     const req = {
       protocol: 'http',
       hostname: 'example.com',
-      get: jest.fn().mockReturnValue('example.com'),
-    };
+      get: getFunc,
+    } as any as Request;
+
+    const redirect = vi.fn();
     const res = {
-      redirect: jest.fn().mockReturnValue('redirect'),
-    };
-    const next = jest.fn();
+      redirect,
+    } as any as ResWithSafeRedirect;
+    const next = vi.fn();
 
     test('redirects to \'/\' because specified url causes open redirect vulnerability', () => {
       registerSafeRedirect(req, res, next);
 
-      const result = res.safeRedirect('//evil.example.com');
+      res.safeRedirect('//evil.example.com');
 
       expect(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('/');
-      expect(result).toBe('redirect');
     });
 
     test('redirects to \'/\' because specified host without port is not in whitelist', () => {
       registerSafeRedirect(req, res, next);
 
-      const result = res.safeRedirect('http://white1.example.com/path/to/page');
+      res.safeRedirect('http://white1.example.com/path/to/page');
 
       expect(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('/');
-      expect(result).toBe('redirect');
     });
 
     test('redirects to the specified local url', () => {
       registerSafeRedirect(req, res, next);
 
-      const result = res.safeRedirect('/path/to/page');
+      res.safeRedirect('/path/to/page');
 
       expect(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('http://example.com/path/to/page');
-      expect(result).toBe('redirect');
     });
 
     test('redirects to the specified local url (fqdn)', () => {
       registerSafeRedirect(req, res, next);
 
-      const result = res.safeRedirect('http://example.com/path/to/page');
+      res.safeRedirect('http://example.com/path/to/page');
 
       expect(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('http://example.com/path/to/page');
-      expect(result).toBe('redirect');
     });
 
     test('redirects to the specified whitelisted url (white1.example.com:8080)', () => {
       registerSafeRedirect(req, res, next);
 
-      const result = res.safeRedirect('http://white1.example.com:8080/path/to/page');
+      res.safeRedirect('http://white1.example.com:8080/path/to/page');
 
       expect(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('http://white1.example.com:8080/path/to/page');
-      expect(result).toBe('redirect');
     });
 
     test('redirects to the specified whitelisted url (white2.example.com:8080)', () => {
       registerSafeRedirect(req, res, next);
 
-      const result = res.safeRedirect('http://white2.example.com:8080/path/to/page');
+      res.safeRedirect('http://white2.example.com:8080/path/to/page');
 
       expect(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('http://white2.example.com:8080/path/to/page');
-      expect(result).toBe('redirect');
     });
 
   });

+ 6 - 4
apps/app/src/server/middlewares/safe-redirect.ts

@@ -4,7 +4,7 @@
  * Usage: app.use(require('middlewares/safe-redirect')(['example.com', 'some.example.com:8080']))
  */
 
-import {
+import type {
   Request, Response, NextFunction,
 } from 'express';
 
@@ -31,13 +31,13 @@ function isInWhitelist(whitelistOfHosts: string[], redirectToFqdn: string): bool
 }
 
 
-type ResWithSafeRedirect = Response & {
+export type ResWithSafeRedirect = Response & {
   safeRedirect: (redirectTo?: string) => void,
 }
 
-module.exports = (whitelistOfHosts: string[]) => {
+const factory = (whitelistOfHosts: string[]) => {
 
-  return function(req: Request, res: ResWithSafeRedirect, next: NextFunction) {
+  return (req: Request, res: ResWithSafeRedirect, next: NextFunction): void => {
 
     // extend res object
     res.safeRedirect = function(redirectTo?: string) {
@@ -75,3 +75,5 @@ module.exports = (whitelistOfHosts: string[]) => {
   };
 
 };
+
+export default factory;

+ 12 - 0
apps/app/src/server/models/.eslintrc.js

@@ -0,0 +1,12 @@
+const rulesDirPlugin = require('eslint-plugin-rulesdir');
+
+rulesDirPlugin.RULES_DIR = 'src/server/models/eslint-rules-dir';
+
+module.exports = {
+  plugins: [
+    'rulesdir',
+  ],
+  rules: {
+    'rulesdir/no-populate': 'warn',
+  },
+};

+ 22 - 72
apps/app/src/server/models/bookmark-folder.ts

@@ -3,7 +3,7 @@ import monggoose, {
   Types, Document, Model, Schema,
 } from 'mongoose';
 
-import { IBookmarkFolder, BookmarkFolderItems, MyBookmarkList } from '~/interfaces/bookmark-info';
+import { BookmarkFolderItems, IBookmarkFolder } from '~/interfaces/bookmark-info';
 import { IPageHasId } from '~/interfaces/page';
 
 import loggerFactory from '../../utils/logger';
@@ -26,11 +26,9 @@ export interface BookmarkFolderDocument extends Document {
 
 export interface BookmarkFolderModel extends Model<BookmarkFolderDocument>{
   createByParameters(params: IBookmarkFolder): Promise<BookmarkFolderDocument>
-  findFolderAndChildren(user: Types.ObjectId | string, parentId?: Types.ObjectId | string): Promise<BookmarkFolderItems[]>
   deleteFolderAndChildren(bookmarkFolderId: Types.ObjectId | string): Promise<{deletedCount: number}>
-  updateBookmarkFolder(bookmarkFolderId: string, name: string, parent: string | null): Promise<BookmarkFolderDocument>
+  updateBookmarkFolder(bookmarkFolderId: string, name: string, parent: string | null, children: BookmarkFolderItems[]): Promise<BookmarkFolderDocument>
   insertOrUpdateBookmarkedPage(pageId: IPageHasId, userId: Types.ObjectId | string, folderId: string | null): Promise<BookmarkFolderDocument | null>
-  findUserRootBookmarksItem(userId: Types.ObjectId| string): Promise<MyBookmarkList>
   updateBookmark(pageId: Types.ObjectId | string, status: boolean, userId: Types.ObjectId| string): Promise<BookmarkFolderDocument | null>
 }
 
@@ -83,45 +81,6 @@ bookmarkFolderSchema.statics.createByParameters = async function(params: IBookma
   return bookmarkFolder;
 };
 
-bookmarkFolderSchema.statics.findFolderAndChildren = async function(
-    userId: Types.ObjectId | string,
-    parentId?: Types.ObjectId | string,
-): Promise<BookmarkFolderItems[]> {
-  const folderItems: BookmarkFolderItems[] = [];
-
-  const folders = await this.find({ owner: userId, parent: parentId })
-    .populate('children')
-    .populate({
-      path: 'bookmarks',
-      model: 'Bookmark',
-      populate: {
-        path: 'page',
-        model: 'Page',
-      },
-    });
-
-  const promises = folders.map(async(folder) => {
-    const children = await this.findFolderAndChildren(userId, folder._id);
-    const {
-      _id, name, owner, bookmarks, parent,
-    } = folder;
-
-    const res = {
-      _id: _id.toString(),
-      name,
-      owner,
-      bookmarks,
-      children,
-      parent,
-    };
-    return res;
-  });
-
-  const results = await Promise.all(promises) as unknown as BookmarkFolderItems[];
-  folderItems.push(...results);
-  return folderItems;
-};
-
 bookmarkFolderSchema.statics.deleteFolderAndChildren = async function(bookmarkFolderId: Types.ObjectId | string): Promise<{deletedCount: number}> {
   const bookmarkFolder = await this.findById(bookmarkFolderId);
   // Delete parent and all children folder
@@ -145,7 +104,12 @@ bookmarkFolderSchema.statics.deleteFolderAndChildren = async function(bookmarkFo
   return { deletedCount };
 };
 
-bookmarkFolderSchema.statics.updateBookmarkFolder = async function(bookmarkFolderId: string, name: string, parentId: string | null):
+bookmarkFolderSchema.statics.updateBookmarkFolder = async function(
+    bookmarkFolderId: string,
+    name: string,
+    parentId: string | null,
+    children: BookmarkFolderItems[],
+):
  Promise<BookmarkFolderDocument> {
   const updateFields: {name: string, parent: Types.ObjectId | null} = {
     name: '',
@@ -163,8 +127,7 @@ bookmarkFolderSchema.statics.updateBookmarkFolder = async function(bookmarkFolde
     if (parentFolder?.parent != null) {
       throw new Error('Update bookmark folder failed');
     }
-    const bookmarkFolder = await this.findById(bookmarkFolderId).populate('children');
-    if (bookmarkFolder?.children?.length !== 0) {
+    if (children.length !== 0) {
       throw new Error('Update bookmark folder failed');
     }
   }
@@ -179,43 +142,30 @@ bookmarkFolderSchema.statics.updateBookmarkFolder = async function(bookmarkFolde
 
 bookmarkFolderSchema.statics.insertOrUpdateBookmarkedPage = async function(pageId: IPageHasId, userId: Types.ObjectId | string, folderId: string | null):
 Promise<BookmarkFolderDocument | null> {
-
-  // Create bookmark or update existing
-  const bookmarkedPage = await Bookmark.findOneAndUpdate({ page: pageId, user: userId }, { page: pageId, user: userId }, { new: true, upsert: true });
+  // Find bookmark
+  const bookmarkedPage = await Bookmark.findOne({ page: pageId, user: userId }, { new: true, upsert: true });
 
   // Remove existing bookmark in bookmark folder
-  await this.updateMany({}, { $pull: { bookmarks:  bookmarkedPage._id } });
-
-  // Insert bookmark into bookmark folder
-  if (folderId != null) {
-    const bookmarkFolder = await this.findByIdAndUpdate(folderId, { $addToSet: { bookmarks: bookmarkedPage } }, { new: true, upsert: true });
-    return bookmarkFolder;
+  await this.updateMany({ owner: userId }, { $pull: { bookmarks:  bookmarkedPage?._id } });
+  if (folderId == null) {
+    return null;
   }
 
-  return null;
-};
+  // Insert bookmark into bookmark folder
+  const bookmarkFolder = await this.findByIdAndUpdate(
+    { _id: folderId, owner: userId },
+    { $addToSet: { bookmarks: bookmarkedPage } },
+    { new: true, upsert: true },
+  );
 
-bookmarkFolderSchema.statics.findUserRootBookmarksItem = async function(userId: Types.ObjectId | string): Promise<MyBookmarkList> {
-  const bookmarkIdsInFolders = await this.distinct('bookmarks', { owner: userId });
-  const userRootBookmarks: MyBookmarkList = await Bookmark.find({
-    _id: { $nin: bookmarkIdsInFolders },
-    user: userId,
-  }).populate({
-    path: 'page',
-    model: 'Page',
-    populate: {
-      path: 'lastUpdateUser',
-      model: 'User',
-    },
-  });
-  return userRootBookmarks;
+  return bookmarkFolder;
 };
 
 bookmarkFolderSchema.statics.updateBookmark = async function(pageId: Types.ObjectId | string, status: boolean, userId: Types.ObjectId | string):
 Promise<BookmarkFolderDocument | null> {
   // If isBookmarked
   if (status) {
-    const bookmarkedPage = await Bookmark.findOne({ page: pageId });
+    const bookmarkedPage = await Bookmark.findOne({ page: pageId, user: userId });
     const bookmarkFolder = await this.findOne({ owner: userId, bookmarks: { $in: [bookmarkedPage?._id] } });
     if (bookmarkFolder != null) {
       await this.updateOne({ owner: userId, _id: bookmarkFolder._id }, { $pull: { bookmarks:  bookmarkedPage?._id } });

+ 27 - 0
apps/app/src/server/models/eslint-rules-dir/no-populate.js

@@ -0,0 +1,27 @@
+/**
+ * @typedef {import('eslint').Rule} Rule
+ * @typedef {import('./lib/html.js').HtmlOptions} HtmlOptions
+ */
+
+/** @type {Rule.RuleModule} */
+module.exports = {
+  meta: {
+    type: 'problem',
+  },
+  /**
+   * @property {Rule.RuleContext} context
+   * @return {Rule.RuleListener}
+   */
+  create: (context) => {
+    return {
+      CallExpression(node) {
+        if (node.callee.property && node.callee.property.name === 'populate') {
+          context.report({
+            node,
+            message: "The 'populate' method should not be called in model modules.",
+          });
+        }
+      },
+    };
+  },
+};

+ 25 - 0
apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts

@@ -0,0 +1,25 @@
+import { test } from 'vitest';
+
+import { RuleTester } from 'eslint';
+
+import noPopulate from '../no-populate';
+
+const ruleTester = new RuleTester({
+  parserOptions: {
+    ecmaVersion: 2015,
+  },
+});
+
+test('test no-populate', () => {
+  ruleTester.run('no-populate', noPopulate, {
+    valid: [
+      { code: 'Model.find();' },
+    ],
+    invalid: [
+      {
+        code: "Model.find().populate('children');",
+        errors: [{ message: "The 'populate' method should not be called in model modules." }],
+      },
+    ],
+  });
+});

+ 25 - 0
apps/app/src/server/models/serializers/bookmark-serializer.js

@@ -0,0 +1,25 @@
+const { serializePageSecurely } = require('./page-serializer');
+
+function serializeInsecurePageAttributes(bookmark) {
+  if (bookmark.page != null && bookmark.page._id != null) {
+    bookmark.page = serializePageSecurely(bookmark.page);
+  }
+  return bookmark;
+}
+
+function serializeBookmarkSecurely(bookmark) {
+  let serialized = bookmark;
+
+  // invoke toObject if bookmark is a model instance
+  if (bookmark.toObject != null) {
+    serialized = bookmark.toObject();
+  }
+
+  serializeInsecurePageAttributes(serialized);
+
+  return serialized;
+}
+
+module.exports = {
+  serializeBookmarkSecurely,
+};

+ 9 - 9
apps/app/src/server/models/user.js

@@ -265,31 +265,31 @@ module.exports = function(crowi) {
     });
   };
 
-  userSchema.methods.removeFromAdmin = async function() {
-    logger.debug('Remove from admin', this);
-    this.admin = 0;
+  userSchema.methods.grantAdmin = async function() {
+    logger.debug('Grant Admin', this);
+    this.admin = 1;
     return this.save();
   };
 
-  userSchema.methods.makeAdmin = async function() {
-    logger.debug('Admin', this);
-    this.admin = 1;
+  userSchema.methods.revokeAdmin = async function() {
+    logger.debug('Revove admin', this);
+    this.admin = 0;
     return this.save();
   };
 
   userSchema.methods.grantReadOnly = async function() {
-    logger.debug('Grant read only flag', this);
+    logger.debug('Grant read only access', this);
     this.readOnly = 1;
     return this.save();
   };
 
   userSchema.methods.revokeReadOnly = async function() {
-    logger.debug('Revoke read only flag', this);
+    logger.debug('Revoke read only access', this);
     this.readOnly = 0;
     return this.save();
   };
 
-  userSchema.methods.asyncMakeAdmin = async function(callback) {
+  userSchema.methods.asyncGrantAdmin = async function(callback) {
     this.admin = 1;
     return this.save();
   };

+ 61 - 5
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -1,14 +1,16 @@
 import { ErrorV3 } from '@growi/core';
 import { body } from 'express-validator';
+import { Types } from 'mongoose';
 
+import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { InvalidParentBookmarkFolderError } from '~/server/models/errors';
+import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
 import loggerFactory from '~/utils/logger';
 
 import BookmarkFolder from '../../models/bookmark-folder';
 
 const logger = loggerFactory('growi:routes:apiv3:bookmark-folder');
-
 const express = require('express');
 
 const router = express.Router();
@@ -23,6 +25,8 @@ const validator = {
           throw new Error('Maximum folder hierarchy of 2 levels');
         }
       }),
+    body('children').optional().isArray().withMessage('Children must be an array'),
+    body('bookmarkFolderId').optional().isMongoId().withMessage('Bookark Folder ID must be a valid mongo ID'),
   ],
   bookmarkPage: [
     body('pageId').isMongoId().withMessage('Page ID must be a valid mongo ID'),
@@ -52,6 +56,7 @@ module.exports = (crowi) => {
       return res.apiv3({ bookmarkFolder });
     }
     catch (err) {
+      logger.error(err);
       if (err instanceof InvalidParentBookmarkFolderError) {
         return res.apiv3Err(new ErrorV3(err.message, 'failed_to_create_bookmark_folder'));
       }
@@ -60,14 +65,60 @@ module.exports = (crowi) => {
   });
 
   // List bookmark folders and child
-  router.get('/list', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.get('/list/:userId', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+    const { userId } = req.params;
+
+    const getBookmarkFolders = async(
+        userId: Types.ObjectId | string,
+        parentFolderId?: Types.ObjectId | string,
+    ) => {
+      const folders = await BookmarkFolder.find({ owner: userId, parent: parentFolderId })
+        .populate('children')
+        .populate({
+          path: 'bookmarks',
+          model: 'Bookmark',
+          populate: {
+            path: 'page',
+            model: 'Page',
+            populate: {
+              path: 'lastUpdateUser',
+              model: 'User',
+            },
+          },
+        }).exec();
+
+      const returnValue: BookmarkFolderItems[] = [];
+
+      const promises = folders.map(async(folder: BookmarkFolderItems) => {
+        const children = await getBookmarkFolders(userId, folder._id);
+
+        // !! DO NOT THIS SERIALIZING OUTSIDE OF PROMISES !! -- 05.23.2023 ryoji-s
+        // Serializing outside of promises will cause not populated.
+        const bookmarks = folder.bookmarks.map(bookmark => serializeBookmarkSecurely(bookmark));
+
+        const res = {
+          _id: folder._id.toString(),
+          name: folder.name,
+          owner: folder.owner,
+          bookmarks,
+          children,
+          parent: folder.parent,
+        };
+        return res;
+      });
+
+      const results = await Promise.all(promises) as unknown as BookmarkFolderItems[];
+      returnValue.push(...results);
+      return returnValue;
+    };
 
     try {
-      const bookmarkFolderItems = await BookmarkFolder.findFolderAndChildren(req.user?._id);
+      const bookmarkFolderItems = await getBookmarkFolders(userId, undefined);
 
       return res.apiv3({ bookmarkFolderItems });
     }
     catch (err) {
+      logger.error(err);
       return res.apiv3Err(err, 500);
     }
   });
@@ -87,12 +138,15 @@ module.exports = (crowi) => {
   });
 
   router.put('/', accessTokenParser, loginRequiredStrictly, validator.bookmarkFolder, async(req, res) => {
-    const { bookmarkFolderId, name, parent } = req.body;
+    const {
+      bookmarkFolderId, name, parent, children,
+    } = req.body;
     try {
-      const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(bookmarkFolderId, name, parent);
+      const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(bookmarkFolderId, name, parent, children);
       return res.apiv3({ bookmarkFolder });
     }
     catch (err) {
+      logger.error(err);
       return res.apiv3Err(err, 500);
     }
   });
@@ -107,6 +161,7 @@ module.exports = (crowi) => {
       return res.apiv3({ bookmarkFolder });
     }
     catch (err) {
+      logger.error(err);
       return res.apiv3Err(err, 500);
     }
   });
@@ -119,6 +174,7 @@ module.exports = (crowi) => {
       return res.apiv3({ bookmarkFolder });
     }
     catch (err) {
+      logger.error(err);
       return res.apiv3Err(err, 500);
     }
   });

+ 17 - 7
apps/app/src/server/routes/apiv3/bookmarks.js

@@ -1,5 +1,6 @@
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -201,14 +202,23 @@ module.exports = (crowi) => {
       return res.apiv3Err('User id is not found or forbidden', 400);
     }
     try {
-      const userRootBookmarks = await BookmarkFolder.findUserRootBookmarksItem(userId);
-      userRootBookmarks.forEach((bookmark) => {
-        if (bookmark.page.lastUpdateUser != null && bookmark.page.lastUpdateUser instanceof User) {
-          bookmark.page.lastUpdateUser = serializeUserSecurely(bookmark.page.lastUpdateUser);
-        }
-      });
+      const bookmarkIdsInFolders = await BookmarkFolder.distinct('bookmarks', { owner: userId });
+      const userRootBookmarks = await Bookmark.find({
+        _id: { $nin: bookmarkIdsInFolders },
+        user: userId,
+      }).populate({
+        path: 'page',
+        model: 'Page',
+        populate: {
+          path: 'lastUpdateUser',
+          model: 'User',
+        },
+      }).exec();
+
+      // serialize Bookmark
+      const serializedUserRootBookmarks = userRootBookmarks.map(bookmark => serializeBookmarkSecurely(bookmark));
 
-      return res.apiv3({ userRootBookmarks });
+      return res.apiv3({ userRootBookmarks: serializedUserRootBookmarks });
     }
     catch (err) {
       logger.error('get-bookmark-failed', err);

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

@@ -10,12 +10,12 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:users');
 
+const path = require('path');
+
 const express = require('express');
 
 const router = express.Router();
 
-const path = require('path');
-
 const { body, query } = require('express-validator');
 const { isEmail } = require('validator');
 
@@ -453,12 +453,12 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /users/{id}/giveAdmin:
+   *    /users/{id}/grant-admin:
    *      put:
    *        tags: [Users]
-   *        operationId: giveAdminUser
-   *        summary: /users/{id}/giveAdmin
-   *        description: Give user admin
+   *        operationId: grantAdminUser
+   *        summary: /users/{id}/grant-admin
+   *        description: Grant user admin
    *        parameters:
    *          - name: id
    *            in: path
@@ -468,7 +468,7 @@ module.exports = (crowi) => {
    *              type: string
    *        responses:
    *          200:
-   *            description: Give user admin success
+   *            description: Grant user admin success
    *            content:
    *              application/json:
    *                schema:
@@ -477,16 +477,16 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: data of admin user
    */
-  router.put('/:id/giveAdmin', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
+  router.put('/:id/grant-admin', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.params;
 
     try {
       const userData = await User.findById(id);
-      await userData.makeAdmin();
+      await userData.grantAdmin();
 
       const serializedUserData = serializeUserSecurely(userData);
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_GIVE_ADMIN });
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_GRANT_ADMIN });
 
       return res.apiv3({ userData: serializedUserData });
     }
@@ -500,40 +500,40 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /users/{id}/removeAdmin:
+   *    /users/{id}/revoke-admin:
    *      put:
    *        tags: [Users]
-   *        operationId: removeAdminUser
-   *        summary: /users/{id}/removeAdmin
-   *        description: Remove user admin
+   *        operationId: revokeAdminUser
+   *        summary: /users/{id}/revoke-admin
+   *        description: Revoke user admin
    *        parameters:
    *          - name: id
    *            in: path
    *            required: true
-   *            description: id of user for removing admin
+   *            description: id of user for revoking admin
    *            schema:
    *              type: string
    *        responses:
    *          200:
-   *            description: Remove user admin success
+   *            description: Revoke user admin success
    *            content:
    *              application/json:
    *                schema:
    *                  properties:
    *                    userData:
    *                      type: object
-   *                      description: data of removed admin user
+   *                      description: data of revoked admin user
    */
-  router.put('/:id/removeAdmin', loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity, async(req, res) => {
+  router.put('/:id/revoke-admin', loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity, async(req, res) => {
     const { id } = req.params;
 
     try {
       const userData = await User.findById(id);
-      await userData.removeFromAdmin();
+      await userData.revokeAdmin();
 
       const serializedUserData = serializeUserSecurely(userData);
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE_ADMIN });
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REVOKE_ADMIN });
 
       return res.apiv3({ userData: serializedUserData });
     }

+ 221 - 0
apps/app/src/server/service/acl.integ.test.ts

@@ -0,0 +1,221 @@
+// import {
+//   vi,
+//   beforeAll, beforeEach, afterEach,
+//   describe, test, expect,
+// } from 'vitest';
+
+// import { aclService } from './acl';
+// import { configManager } from './config-manager';
+
+
+// describe('AclService', () => {
+//   test("has consts 'isLabeledStatement'", () => {
+//     expect(aclService.labels.SECURITY_RESTRICT_GUEST_MODE_DENY).toBe('Deny');
+//     expect(aclService.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY).toBe('Readonly');
+//     expect(aclService.labels.SECURITY_REGISTRATION_MODE_OPEN).toBe('Open');
+//     expect(aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED).toBe('Restricted');
+//     expect(aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED).toBe('Closed');
+//   });
+// });
+
+// describe('AclService test', () => {
+
+//   const initialEnv = process.env;
+
+//   beforeAll(async() => {
+//     await configManager.loadConfigs();
+//   });
+
+//   afterEach(() => {
+//     process.env = initialEnv;
+//   });
+
+//   describe('isAclEnabled()', () => {
+
+//     test('to be false when FORCE_WIKI_MODE is undefined', async() => {
+//       delete process.env.FORCE_WIKI_MODE;
+
+//       // reload
+//       await configManager.loadConfigs();
+
+//       const result = aclService.isAclEnabled();
+
+//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
+//       expect(wikiMode).toBe(undefined);
+//       expect(result).toBe(true);
+//     });
+
+//     test('to be false when FORCE_WIKI_MODE is dummy string', async() => {
+//       process.env.FORCE_WIKI_MODE = 'dummy string';
+
+//       // reload
+//       await configManager.loadConfigs();
+
+//       const result = aclService.isAclEnabled();
+
+//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
+//       expect(wikiMode).toBe('dummy string');
+//       expect(result).toBe(true);
+//     });
+
+//     test('to be true when FORCE_WIKI_MODE=private', async() => {
+//       process.env.FORCE_WIKI_MODE = 'private';
+
+//       // reload
+//       await configManager.loadConfigs();
+
+//       const result = aclService.isAclEnabled();
+
+//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
+//       expect(wikiMode).toBe('private');
+//       expect(result).toBe(true);
+//     });
+
+//     test('to be false when FORCE_WIKI_MODE=public', async() => {
+//       process.env.FORCE_WIKI_MODE = 'public';
+
+//       // reload
+//       await configManager.loadConfigs();
+
+//       const result = aclService.isAclEnabled();
+
+//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
+//       expect(wikiMode).toBe('public');
+//       expect(result).toBe(false);
+//     });
+
+//   });
+
+
+//   describe('isWikiModeForced()', () => {
+
+//     test('to be false when FORCE_WIKI_MODE is undefined', async() => {
+//       delete process.env.FORCE_WIKI_MODE;
+
+//       // reload
+//       await configManager.loadConfigs();
+
+//       const result = aclService.isWikiModeForced();
+
+//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
+//       expect(wikiMode).toBe(undefined);
+//       expect(result).toBe(false);
+//     });
+
+//     test('to be false when FORCE_WIKI_MODE is dummy string', async() => {
+//       process.env.FORCE_WIKI_MODE = 'dummy string';
+
+//       // reload
+//       await configManager.loadConfigs();
+
+//       const result = aclService.isWikiModeForced();
+
+//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
+//       expect(wikiMode).toBe('dummy string');
+//       expect(result).toBe(false);
+//     });
+
+//     test('to be true when FORCE_WIKI_MODE=private', async() => {
+//       process.env.FORCE_WIKI_MODE = 'private';
+
+//       // reload
+//       await configManager.loadConfigs();
+
+//       const result = aclService.isWikiModeForced();
+
+//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
+//       expect(wikiMode).toBe('private');
+//       expect(result).toBe(true);
+//     });
+
+//     test('to be false when FORCE_WIKI_MODE=public', async() => {
+//       process.env.FORCE_WIKI_MODE = 'public';
+
+//       // reload
+//       await configManager.loadConfigs();
+
+//       const result = aclService.isWikiModeForced();
+
+//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
+//       expect(wikiMode).toBe('public');
+//       expect(result).toBe(true);
+//     });
+
+//   });
+
+
+//   describe('isGuestAllowedToRead()', () => {
+//     let getConfigSpy;
+
+//     beforeEach(async() => {
+//       // prepare spy for ConfigManager.getConfig
+//       getConfigSpy = vi.spyOn(configManager, 'getConfig');
+//     });
+
+//     test('to be false when FORCE_WIKI_MODE=private', async() => {
+//       process.env.FORCE_WIKI_MODE = 'private';
+
+//       // reload
+//       await configManager.loadConfigs();
+
+//       const result = aclService.isGuestAllowedToRead();
+
+//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
+//       expect(wikiMode).toBe('private');
+//       expect(getConfigSpy).not.toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
+//       expect(result).toBe(false);
+//     });
+
+//     test('to be true when FORCE_WIKI_MODE=public', async() => {
+//       process.env.FORCE_WIKI_MODE = 'public';
+
+//       // reload
+//       await configManager.loadConfigs();
+
+//       const result = aclService.isGuestAllowedToRead();
+
+//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
+//       expect(wikiMode).toBe('public');
+//       expect(getConfigSpy).not.toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
+//       expect(result).toBe(true);
+//     });
+
+//     /* eslint-disable indent */
+//     describe.each`
+//       restrictGuestMode   | expected
+//       ${undefined}        | ${false}
+//       ${'Deny'}           | ${false}
+//       ${'Readonly'}       | ${true}
+//       ${'Open'}           | ${false}
+//       ${'Restricted'}     | ${false}
+//       ${'closed'}         | ${false}
+//     `('to be $expected', ({ restrictGuestMode, expected }) => {
+//       test(`when FORCE_WIKI_MODE is undefined and 'security:restrictGuestMode' is '${restrictGuestMode}`, async() => {
+
+//         // reload
+//         await configManager.loadConfigs();
+
+//         // setup mock implementation
+//         getConfigSpy.mockImplementation((ns, key) => {
+//           if (ns === 'crowi' && key === 'security:restrictGuestMode') {
+//             return restrictGuestMode;
+//           }
+//           if (ns === 'crowi' && key === 'security:wikiMode') {
+//             return undefined;
+//           }
+//           throw new Error('Unexpected behavior.');
+//         });
+
+//         const result = aclService.isGuestAllowedToRead();
+
+//         expect(getConfigSpy).toHaveBeenCalledTimes(2);
+//         expect(getConfigSpy).toHaveBeenCalledWith('crowi', 'security:wikiMode');
+//         expect(getConfigSpy).toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
+//         expect(result).toBe(expected);
+//       });
+//     });
+
+//   });
+
+
+// });

+ 23 - 20
apps/app/test/integration/service/config-manager.test.js → apps/app/src/server/service/config-manager.spec.ts

@@ -1,46 +1,49 @@
-import ConfigModel from '~/server/models/config';
+import { mock } from 'vitest-mock-extended';
 
-const { getInstance } = require('../setup-crowi');
+import ConfigModel from '../models/config';
+
+import { configManager } from './config-manager';
+import type { S2sMessagingService } from './s2s-messaging/base';
 
 describe('ConfigManager test', () => {
-  let crowi;
-  let configManager;
 
-  beforeEach(async() => {
-    process.env.CONFIG_PUBSUB_SERVER_TYPE = 'nchan';
+  const s2sMessagingServiceMock = mock<S2sMessagingService>();
 
-    crowi = await getInstance();
-    configManager = crowi.configManager;
+  beforeAll(async() => {
+    process.env.CONFIG_PUBSUB_SERVER_TYPE = 'nchan';
+    configManager.setS2sMessagingService(s2sMessagingServiceMock);
   });
 
 
   describe('updateConfigsInTheSameNamespace()', () => {
 
-    beforeEach(async() => {
-      configManager.s2sMessagingService = {};
-    });
-
-    test('invoke publishUpdateMessage()', async() => {
-      ConfigModel.bulkWrite = jest.fn();
-      configManager.loadConfigs = jest.fn();
-      configManager.publishUpdateMessage = jest.fn();
+    test.concurrent('invoke publishUpdateMessage()', async() => {
+      // setup
+      ConfigModel.bulkWrite = vi.fn();
+      configManager.loadConfigs = vi.fn();
+      configManager.publishUpdateMessage = vi.fn();
 
+      // when
       const dummyConfig = { dummyKey: 'dummyValue' };
       await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig);
 
+      // then
       expect(ConfigModel.bulkWrite).toHaveBeenCalledTimes(1);
       expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
 
-    test('does not invoke publishUpdateMessage()', async() => {
-      ConfigModel.bulkWrite = jest.fn();
-      configManager.loadConfigs = jest.fn();
-      configManager.publishUpdateMessage = jest.fn();
+    test.concurrent('does not invoke publishUpdateMessage()', async() => {
+      // setup
+      ConfigModel.bulkWrite = vi.fn();
+      configManager.loadConfigs = vi.fn();
+      configManager.publishUpdateMessage = vi.fn();
 
+      // when
       const dummyConfig = { dummyKey: 'dummyValue' };
       await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig, true);
 
+      // then
       expect(ConfigModel.bulkWrite).toHaveBeenCalledTimes(1);
       expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();

+ 75 - 85
apps/app/src/server/service/config-manager.ts

@@ -6,8 +6,8 @@ import ConfigModel from '../models/config';
 import S2sMessage from '../models/vo/s2s-message';
 
 import ConfigLoader, { ConfigObject } from './config-loader';
-import { S2sMessagingService } from './s2s-messaging/base';
-import { S2sMessageHandlable } from './s2s-messaging/handlable';
+import type { S2sMessagingService } from './s2s-messaging/base';
+import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 const logger = loggerFactory('growi:service:ConfigManager');
 
@@ -36,7 +36,17 @@ const KEYS_FOR_GCS_USE_ONLY_ENV_OPTION = [
   'gcs:uploadNamespace',
 ];
 
-export default class ConfigManager implements S2sMessageHandlable {
+
+export interface ConfigManager {
+  loadConfigs(): Promise<void>,
+  getConfig(namespace: string, key: string): any,
+  getConfigFromDB(namespace: string, key: string): any,
+  getConfigFromEnvVars(namespace: string, key: string): any,
+  updateConfigsInTheSameNamespace(namespace: string, configs, withoutPublishingS2sMessage?: boolean): Promise<void>
+  removeConfigsInTheSameNamespace(namespace: string, configKeys: string[], withoutPublishingS2sMessage?: boolean): Promise<void>
+}
+
+class ConfigManagerImpl implements ConfigManager, S2sMessageHandlable {
 
   private configLoader: ConfigLoader = new ConfigLoader();
 
@@ -48,6 +58,16 @@ export default class ConfigManager implements S2sMessageHandlable {
 
   private lastLoadedAt?: Date;
 
+  private get isInitialized() {
+    return this.lastLoadedAt != null;
+  }
+
+  private validateInitialized() {
+    if (!this.isInitialized) {
+      throw new Error('The config data has not loaded yet.');
+    }
+  }
+
   /**
    * load configs from the database and the environment variables
    */
@@ -61,68 +81,10 @@ export default class ConfigManager implements S2sMessageHandlable {
     this.lastLoadedAt = new Date();
   }
 
-  /**
-   * Set S2sMessagingServiceDelegator instance
-   * @param s2sMessagingService
-   */
-  setS2sMessagingService(s2sMessagingService: S2sMessagingService): void {
-    this.s2sMessagingService = s2sMessagingService;
-  }
-
-  /**
-   * get a config specified by namespace & key
-   *
-   * Basically, this searches a specified config from configs loaded from the database at first
-   * and then from configs loaded from the environment variables.
-   *
-   * In some case, this search method changes.
-   *
-   * the followings are the meanings of each special return value.
-   * - null:      a specified config is not set.
-   * - undefined: a specified config does not exist.
-   */
-  getConfig(namespace, key) {
-    let value;
-
-    if (this.shouldSearchedFromEnvVarsOnly(namespace, key)) {
-      value = this.searchOnlyFromEnvVarConfigs(namespace, key);
-    }
-    else {
-      value = this.defaultSearch(namespace, key);
-    }
-
-    logger.debug(key, value);
-    return value;
-  }
-
-  /**
-   * get a config specified by namespace and regular expression
-   */
-  getConfigByRegExp(namespace, regexp) {
-    const result = {};
-
-    for (const key of this.configKeys) {
-      if (regexp.test(key)) {
-        result[key] = this.getConfig(namespace, key);
-      }
-    }
-
-    return result;
-  }
-
-  /**
-   * get a config specified by namespace and prefix
-   */
-  getConfigByPrefix(namespace, prefix) {
-    const regexp = new RegExp(`^${prefix}`);
-
-    return this.getConfigByRegExp(namespace, regexp);
-  }
-
   /**
    * generate an array of config keys from this.configObject
    */
-  getConfigKeys() {
+  private getConfigKeys() {
     // type: fromDB, fromEnvVars
     const types = Object.keys(this.configObject);
     let namespaces: string[] = [];
@@ -152,16 +114,46 @@ export default class ConfigManager implements S2sMessageHandlable {
     return keys;
   }
 
-  reloadConfigKeys() {
+  private reloadConfigKeys() {
     this.configKeys = this.getConfigKeys();
   }
 
+
+  /**
+   * get a config specified by namespace & key
+   *
+   * Basically, this searches a specified config from configs loaded from the database at first
+   * and then from configs loaded from the environment variables.
+   *
+   * In some case, this search method changes.
+   *
+   * the followings are the meanings of each special return value.
+   * - null:      a specified config is not set.
+   * - undefined: a specified config does not exist.
+   */
+  getConfig(namespace, key) {
+    this.validateInitialized();
+
+    let value;
+
+    if (this.shouldSearchedFromEnvVarsOnly(namespace, key)) {
+      value = this.searchOnlyFromEnvVarConfigs(namespace, key);
+    }
+    else {
+      value = this.defaultSearch(namespace, key);
+    }
+
+    logger.debug(key, value);
+    return value;
+  }
+
   /**
    * get a config specified by namespace & key from configs loaded from the database
    *
    * **Do not use this unless absolutely necessary. Use getConfig instead.**
    */
   getConfigFromDB(namespace, key) {
+    this.validateInitialized();
     return this.searchOnlyFromDBConfigs(namespace, key);
   }
 
@@ -171,6 +163,7 @@ export default class ConfigManager implements S2sMessageHandlable {
    * **Do not use this unless absolutely necessary. Use getConfig instead.**
    */
   getConfigFromEnvVars(namespace, key) {
+    this.validateInitialized();
     return this.searchOnlyFromEnvVarConfigs(namespace, key);
   }
 
@@ -192,7 +185,7 @@ export default class ConfigManager implements S2sMessageHandlable {
    *  );
    * ```
    */
-  async updateConfigsInTheSameNamespace(namespace, configs, withoutPublishingS2sMessage?) {
+  async updateConfigsInTheSameNamespace(namespace: string, configs, withoutPublishingS2sMessage = false): Promise<void> {
     const queries: any[] = [];
     for (const key of Object.keys(configs)) {
       queries.push({
@@ -235,7 +228,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
    * return whether the specified namespace/key should be retrieved only from env vars
    */
-  shouldSearchedFromEnvVarsOnly(namespace, key) {
+  private shouldSearchedFromEnvVarsOnly(namespace, key) {
     return (namespace === 'crowi' && (
       // siteUrl
       (
@@ -273,7 +266,7 @@ export default class ConfigManager implements S2sMessageHandlable {
    * search a specified config from configs loaded from the database at first
    * and then from configs loaded from the environment variables
    */
-  defaultSearch(namespace, key) {
+  private defaultSearch(namespace, key) {
     // does not exist neither in db nor in env vars
     if (!this.configExistsInDB(namespace, key) && !this.configExistsInEnvVars(namespace, key)) {
       logger.debug(`${namespace}.${key} does not exist neither in db nor in env vars`);
@@ -309,7 +302,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
    * search a specified config from configs loaded from the database
    */
-  searchOnlyFromDBConfigs(namespace, key) {
+  private searchOnlyFromDBConfigs(namespace, key) {
     if (!this.configExistsInDB(namespace, key)) {
       return undefined;
     }
@@ -320,7 +313,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
    * search a specified config from configs loaded from the environment variables
    */
-  searchOnlyFromEnvVarConfigs(namespace, key) {
+  private searchOnlyFromEnvVarConfigs(namespace, key) {
     if (!this.configExistsInEnvVars(namespace, key)) {
       return undefined;
     }
@@ -331,7 +324,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
    * check whether a specified config exists in configs loaded from the database
    */
-  configExistsInDB(namespace, key) {
+  private configExistsInDB(namespace, key) {
     if (this.configObject.fromDB[namespace] === undefined) {
       return false;
     }
@@ -342,7 +335,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
    * check whether a specified config exists in configs loaded from the environment variables
    */
-  configExistsInEnvVars(namespace, key) {
+  private configExistsInEnvVars(namespace, key) {
     if (this.configObject.fromEnvVars[namespace] === undefined) {
       return false;
     }
@@ -350,10 +343,18 @@ export default class ConfigManager implements S2sMessageHandlable {
     return this.configObject.fromEnvVars[namespace][key] !== undefined;
   }
 
-  convertInsertValue(value) {
+  private convertInsertValue(value) {
     return JSON.stringify(value === '' ? null : value);
   }
 
+  /**
+   * Set S2sMessagingServiceDelegator instance
+   * @param s2sMessagingService
+   */
+  setS2sMessagingService(s2sMessagingService: S2sMessagingService): void {
+    this.s2sMessagingService = s2sMessagingService;
+  }
+
   async publishUpdateMessage() {
     const s2sMessage = new S2sMessage('configUpdated', { updatedAt: new Date() });
 
@@ -385,18 +386,7 @@ export default class ConfigManager implements S2sMessageHandlable {
     return this.loadConfigs();
   }
 
-  /**
-   * Returns file upload total limit in bytes.
-   * Reference to previous implementation is
-   * {@link https://github.com/weseek/growi/blob/798e44f14ad01544c1d75ba83d4dfb321a94aa0b/src/server/service/file-uploader/gridfs.js#L86-L88}
-   * @returns file upload total limit in bytes
-   */
-  getFileUploadTotalLimit(): number {
-    const fileUploadTotalLimit = this.getConfig('crowi', 'app:fileUploadType') === 'mongodb'
-      // Use app:fileUploadTotalLimit if gridfs:totalLimit is null (default for gridfs:totalLimit is null)
-      ? this.getConfig('crowi', 'gridfs:totalLimit') ?? this.getConfig('crowi', 'app:fileUploadTotalLimit')
-      : this.getConfig('crowi', 'app:fileUploadTotalLimit');
-    return fileUploadTotalLimit;
-  }
-
 }
+
+// export the singleton instance
+export const configManager = new ConfigManagerImpl();

+ 2 - 2
apps/app/src/server/service/customize.ts

@@ -7,9 +7,9 @@ import loggerFactory from '~/utils/logger';
 
 import S2sMessage from '../models/vo/s2s-message';
 
-import ConfigManager from './config-manager';
+import type { ConfigManager } from './config-manager';
 import type { IPluginService } from './plugin';
-import { S2sMessageHandlable } from './s2s-messaging/handlable';
+import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 
 const logger = loggerFactory('growi:service:CustomizeService');

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

@@ -1,9 +1,10 @@
 import loggerFactory from '~/utils/logger';
 
 import S2sMessage from '../models/vo/s2s-message';
-import ConfigManager from './config-manager';
-import { S2sMessagingService } from './s2s-messaging/base';
-import { S2sMessageHandlable } from './s2s-messaging/handlable';
+
+import type { ConfigManager } from './config-manager';
+import type { S2sMessagingService } from './s2s-messaging/base';
+import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 const logger = loggerFactory('growi:service:FileUploaderSwitch');
 

+ 47 - 12
apps/app/src/server/service/file-uploader/aws.ts

@@ -13,6 +13,10 @@ import urljoin from 'url-join';
 
 import loggerFactory from '~/utils/logger';
 
+import { configManager } from '../config-manager';
+
+import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+
 
 const logger = loggerFactory('growi:service:fileUploaderAws');
 
@@ -37,10 +41,41 @@ type AwsConfig = {
   forcePathStyle?: boolean
 }
 
+// TODO: rewrite this module to be a type-safe implementation
+class AwsFileUploader extends AbstractFileUploader {
+
+  /**
+   * @inheritdoc
+   */
+  override isValidUploadSettings(): boolean {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override saveFile(param: SaveFileParam) {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override deleteFiles() {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override respond(res: Response, attachment: Response): void {
+    throw new Error('Method not implemented.');
+  }
+
+}
+
 module.exports = (crowi) => {
-  const Uploader = require('./uploader');
-  const { configManager } = crowi;
-  const lib = new Uploader(crowi);
+  const lib = new AwsFileUploader(crowi);
 
   const getAwsConfig = (): AwsConfig => {
     return {
@@ -100,7 +135,7 @@ module.exports = (crowi) => {
     return !configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
   };
 
-  lib.respond = async function(res, attachment) {
+  (lib as any).respond = async function(res, attachment) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -134,12 +169,12 @@ module.exports = (crowi) => {
 
   };
 
-  lib.deleteFile = async function(attachment) {
+  (lib as any).deleteFile = async function(attachment) {
     const filePath = getFilePathOnStorage(attachment);
-    return lib.deleteFileByFilePath(filePath);
+    return (lib as any).deleteFileByFilePath(filePath);
   };
 
-  lib.deleteFiles = async function(attachments) {
+  (lib as any).deleteFiles = async function(attachments) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -157,7 +192,7 @@ module.exports = (crowi) => {
     return s3.send(new DeleteObjectsCommand(totalParams));
   };
 
-  lib.deleteFileByFilePath = async function(filePath) {
+  (lib as any).deleteFileByFilePath = async function(filePath) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -179,7 +214,7 @@ module.exports = (crowi) => {
     return s3.send(new DeleteObjectCommand(params));
   };
 
-  lib.uploadAttachment = async function(fileStream, attachment) {
+  (lib as any).uploadAttachment = async function(fileStream, attachment) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -216,7 +251,7 @@ module.exports = (crowi) => {
     return s3.send(new PutObjectCommand(params));
   };
 
-  lib.findDeliveryFile = async function(attachment) {
+  (lib as any).findDeliveryFile = async function(attachment) {
     if (!lib.getIsReadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -249,7 +284,7 @@ module.exports = (crowi) => {
     return stream;
   };
 
-  lib.checkLimit = async function(uploadFileSize) {
+  (lib as any).checkLimit = async function(uploadFileSize) {
     const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
     const totalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
@@ -258,7 +293,7 @@ module.exports = (crowi) => {
   /**
    * List files in storage
    */
-  lib.listFiles = async function() {
+  (lib as any).listFiles = async function() {
     if (!lib.getIsReadable()) {
       throw new Error('AWS is not configured.');
     }

+ 151 - 0
apps/app/src/server/service/file-uploader/file-uploader.ts

@@ -0,0 +1,151 @@
+import { randomUUID } from 'crypto';
+
+import loggerFactory from '~/utils/logger';
+
+import { configManager } from '../config-manager';
+
+const logger = loggerFactory('growi:service:fileUploader');
+
+
+export type SaveFileParam = {
+  filePath: string,
+  contentType: string,
+  data,
+}
+
+export type CheckLimitResult = {
+  isUploadable: boolean,
+  errorMessage?: string,
+}
+
+export interface FileUploader {
+  getIsUploadable(): boolean,
+  isWritable(): Promise<boolean>,
+  getIsReadable(): boolean,
+  isValidUploadSettings(): boolean,
+  getFileUploadEnabled(): boolean,
+  saveFile(param: SaveFileParam): Promise<any>,
+  deleteFiles(): void,
+  getFileUploadTotalLimit(): number,
+  getTotalFileSize(): Promise<number>,
+  doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<CheckLimitResult>,
+  canRespond(): boolean
+  respond(res: Response, attachment: Response): void,
+}
+
+export abstract class AbstractFileUploader implements FileUploader {
+
+  private crowi;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  getIsUploadable() {
+    return !configManager.getConfig('crowi', 'app:fileUploadDisabled') && this.isValidUploadSettings();
+  }
+
+  /**
+   * Returns whether write opration to the storage is permitted
+   * @returns Whether write opration to the storage is permitted
+   */
+  async isWritable() {
+    const filePath = `${randomUUID()}.growi`;
+    const data = 'This file was created during g2g transfer to check write permission. You can safely remove this file.';
+
+    try {
+      await this.saveFile({
+        filePath,
+        contentType: 'text/plain',
+        data,
+      });
+      // TODO: delete tmp file in background
+
+      return true;
+    }
+    catch (err) {
+      logger.error(err);
+      return false;
+    }
+  }
+
+  // File reading is possible even if uploading is disabled
+  getIsReadable() {
+    return this.isValidUploadSettings();
+  }
+
+  abstract isValidUploadSettings(): boolean;
+
+  getFileUploadEnabled() {
+    if (!this.getIsUploadable()) {
+      return false;
+    }
+
+    return !!configManager.getConfig('crowi', 'app:fileUpload');
+  }
+
+  abstract saveFile(param: SaveFileParam);
+
+  abstract deleteFiles();
+
+  /**
+   * Returns file upload total limit in bytes.
+   * Reference to previous implementation is
+   * {@link https://github.com/weseek/growi/blob/798e44f14ad01544c1d75ba83d4dfb321a94aa0b/src/server/service/file-uploader/gridfs.js#L86-L88}
+   * @returns file upload total limit in bytes
+   */
+  getFileUploadTotalLimit() {
+    const fileUploadTotalLimit = configManager.getConfig('crowi', 'app:fileUploadType') === 'mongodb'
+      // Use app:fileUploadTotalLimit if gridfs:totalLimit is null (default for gridfs:totalLimit is null)
+      ? configManager.getConfig('crowi', 'gridfs:totalLimit') ?? configManager.getConfig('crowi', 'app:fileUploadTotalLimit')
+      : configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
+    return fileUploadTotalLimit;
+  }
+
+  /**
+   * Get total file size
+   * @returns Total file size
+   */
+  async getTotalFileSize() {
+    const Attachment = this.crowi.model('Attachment');
+
+    // Get attachment total file size
+    const res = await Attachment.aggregate().group({
+      _id: null,
+      total: { $sum: '$fileSize' },
+    });
+
+    // res is [] if not using
+    return res.length === 0 ? 0 : res[0].total;
+  }
+
+  /**
+   * Check files size limits for all uploaders
+   *
+   */
+  async doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<CheckLimitResult> {
+    if (uploadFileSize > maxFileSize) {
+      return { isUploadable: false, errorMessage: 'File size exceeds the size limit per file' };
+    }
+
+    const usingFilesSize = await this.getTotalFileSize();
+    if (usingFilesSize + uploadFileSize > totalLimit) {
+      return { isUploadable: false, errorMessage: 'Uploading files reaches limit' };
+    }
+
+    return { isUploadable: true };
+  }
+
+  /**
+   * Checks if Uploader can respond to the HTTP request.
+   */
+  canRespond(): boolean {
+    return false;
+  }
+
+  /**
+   * Respond to the HTTP request.
+   */
+  abstract respond(res: Response, attachment: Response): void;
+
+}

+ 3 - 2
apps/app/src/server/service/file-uploader/gcs.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { AbstractFileUploader } from './file-uploader';
+
 const logger = loggerFactory('growi:service:fileUploaderAws');
 
 const { Storage } = require('@google-cloud/storage');
@@ -9,9 +11,8 @@ let _instance;
 
 
 module.exports = function(crowi) {
-  const Uploader = require('./uploader');
   const { configManager } = crowi;
-  const lib = new Uploader(crowi);
+  const lib = new AbstractFileUploader(crowi);
 
   function getGcsBucket() {
     return configManager.getConfig('crowi', 'gcs:bucket');

+ 49 - 12
apps/app/src/server/service/file-uploader/gridfs.js → apps/app/src/server/service/file-uploader/gridfs.ts

@@ -1,16 +1,53 @@
 import { Readable } from 'stream';
+import util from 'util';
+
+import mongoose from 'mongoose';
 
 import loggerFactory from '~/utils/logger';
 
+import { configManager } from '../config-manager';
+
+import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+
 const logger = loggerFactory('growi:service:fileUploaderGridfs');
-const util = require('util');
 
-const mongoose = require('mongoose');
+
+// TODO: rewrite this module to be a type-safe implementation
+class GridfsFileUploader extends AbstractFileUploader {
+
+  /**
+   * @inheritdoc
+   */
+  override isValidUploadSettings(): boolean {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override saveFile(param: SaveFileParam) {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override deleteFiles() {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override respond(res: Response, attachment: Response): void {
+    throw new Error('Method not implemented.');
+  }
+
+}
+
 
 module.exports = function(crowi) {
-  const Uploader = require('./uploader');
-  const { configManager } = crowi;
-  const lib = new Uploader(crowi);
+  const lib = new GridfsFileUploader(crowi);
   const COLLECTION_NAME = 'attachmentFiles';
   const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
 
@@ -33,7 +70,7 @@ module.exports = function(crowi) {
     return true;
   };
 
-  lib.deleteFile = async function(attachment) {
+  (lib as any).deleteFile = async function(attachment) {
     let filenameValue = attachment.fileName;
 
     if (attachment.filePath != null) { // backward compatibility for v3.3.x or below
@@ -49,7 +86,7 @@ module.exports = function(crowi) {
     return AttachmentFile.promisifiedUnlink({ _id: attachmentFile._id });
   };
 
-  lib.deleteFiles = async function(attachments) {
+  (lib as any).deleteFiles = async function(attachments) {
     const filenameValues = attachments.map((attachment) => {
       return attachment.fileName;
     });
@@ -87,13 +124,13 @@ module.exports = function(crowi) {
    * - per-file size limit (specified by MAX_FILE_SIZE)
    * - mongodb(gridfs) size limit (specified by MONGO_GRIDFS_TOTAL_LIMIT)
    */
-  lib.checkLimit = async function(uploadFileSize) {
+  (lib as any).checkLimit = async function(uploadFileSize) {
     const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
-    const totalLimit = configManager.getFileUploadTotalLimit();
+    const totalLimit = lib.getFileUploadTotalLimit();
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
   };
 
-  lib.uploadAttachment = async function(fileStream, attachment) {
+  (lib as any).uploadAttachment = async function(fileStream, attachment) {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
     return AttachmentFile.promisifiedWrite(
@@ -125,7 +162,7 @@ module.exports = function(crowi) {
    * @param {Attachment} attachment
    * @return {stream.Readable} readable stream
    */
-  lib.findDeliveryFile = async function(attachment) {
+  (lib as any).findDeliveryFile = async function(attachment) {
     let filenameValue = attachment.fileName;
 
     if (attachment.filePath != null) { // backward compatibility for v3.3.x or below
@@ -145,7 +182,7 @@ module.exports = function(crowi) {
   /**
    * List files in storage
    */
-  lib.listFiles = async function() {
+  (lib as any).listFiles = async function() {
     const attachmentFiles = await AttachmentFile.find();
     return attachmentFiles.map(({ filename: name, length: size }) => ({
       name, size,

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

@@ -2,6 +2,8 @@ import { Readable } from 'stream';
 
 import loggerFactory from '~/utils/logger';
 
+import { AbstractFileUploader } from './file-uploader';
+
 const logger = loggerFactory('growi:service:fileUploaderLocal');
 
 const fs = require('fs');
@@ -13,9 +15,8 @@ const streamToPromise = require('stream-to-promise');
 const urljoin = require('url-join');
 
 module.exports = function(crowi) {
-  const Uploader = require('./uploader');
   const { configManager } = crowi;
-  const lib = new Uploader(crowi);
+  const lib = new AbstractFileUploader(crowi);
   const basePath = path.posix.join(crowi.publicDir, 'uploads');
 
   function getFilePathOnStorage(attachment) {

+ 3 - 2
apps/app/src/server/service/file-uploader/none.js

@@ -1,9 +1,10 @@
 // crowi-fileupload-none
 
+const { AbstractFileUploader } = require('./file-uploader');
+
 module.exports = function(crowi) {
   const debug = require('debug')('growi:service:fileUploaderNone');
-  const Uploader = require('./uploader');
-  const lib = new Uploader(crowi);
+  const lib = new AbstractFileUploader(crowi);
 
   lib.getIsUploadable = function() {
     return false;

+ 0 - 123
apps/app/src/server/service/file-uploader/uploader.js

@@ -1,123 +0,0 @@
-import { randomUUID } from 'crypto';
-
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:service:fileUploader');
-
-// file uploader virtual class
-// 各アップローダーで共通のメソッドはここで定義する
-
-class Uploader {
-
-  constructor(crowi) {
-    this.crowi = crowi;
-    this.configManager = crowi.configManager;
-  }
-
-  getIsUploadable() {
-    return !this.configManager.getConfig('crowi', 'app:fileUploadDisabled') && this.isValidUploadSettings();
-  }
-
-  /**
-   * Returns whether write opration to the storage is permitted
-   * @returns Whether write opration to the storage is permitted
-   */
-  async isWritable() {
-    const filePath = `${randomUUID()}.growi`;
-    const data = 'This file was created during g2g transfer to check write permission. You can safely remove this file.';
-
-    try {
-      await this.saveFile({
-        filePath,
-        contentType: 'text/plain',
-        data,
-      });
-      // TODO: delete tmp file in background
-
-      return true;
-    }
-    catch (err) {
-      logger.error(err);
-      return false;
-    }
-  }
-
-  // File reading is possible even if uploading is disabled
-  getIsReadable() {
-    return this.isValidUploadSettings();
-  }
-
-  isValidUploadSettings() {
-    throw new Error('Implement this');
-  }
-
-  getFileUploadEnabled() {
-    if (!this.getIsUploadable()) {
-      return false;
-    }
-
-    return !!this.configManager.getConfig('crowi', 'app:fileUpload');
-  }
-
-  deleteFiles() {
-    throw new Error('Implemnt this');
-  }
-
-  /**
-   * Get total file size
-   * @returns Total file size
-   */
-  async getTotalFileSize() {
-    const Attachment = this.crowi.model('Attachment');
-
-    // Get attachment total file size
-    const res = await Attachment.aggregate().group({
-      _id: null,
-      total: { $sum: '$fileSize' },
-    });
-
-    // res is [] if not using
-    return res.length === 0 ? 0 : res[0].total;
-  }
-
-  /**
-   * Check files size limits for all uploaders
-   *
-   * @param {*} uploadFileSize
-   * @param {*} maxFileSize
-   * @param {*} totalLimit
-   * @returns
-   * @memberof Uploader
-   */
-  async doCheckLimit(uploadFileSize, maxFileSize, totalLimit) {
-    if (uploadFileSize > maxFileSize) {
-      return { isUploadable: false, errorMessage: 'File size exceeds the size limit per file' };
-    }
-
-    const usingFilesSize = await this.getTotalFileSize();
-    if (usingFilesSize + uploadFileSize > totalLimit) {
-      return { isUploadable: false, errorMessage: 'Uploading files reaches limit' };
-    }
-
-    return { isUploadable: true };
-  }
-
-  /**
-   * Checks if Uploader can respond to the HTTP request.
-   */
-  canRespond() {
-    return false;
-  }
-
-  /**
-   * Respond to the HTTP request.
-   * @param {Response} res
-   * @param {Response} attachment
-   */
-  respond(res, attachment) {
-    throw new Error('Implement this');
-  }
-
-}
-
-module.exports = Uploader;

+ 1 - 1
apps/app/src/server/service/g2g-transfer.ts

@@ -535,7 +535,7 @@ export class G2GTransferReceiverService implements Receiver {
     const { version, configManager, fileUploadService } = this.crowi;
     const userUpperLimit = configManager.getConfig('crowi', 'security:userUpperLimit');
     const fileUploadDisabled = configManager.getConfig('crowi', 'app:fileUploadDisabled');
-    const fileUploadTotalLimit = configManager.getFileUploadTotalLimit();
+    const fileUploadTotalLimit = fileUploadService.getFileUploadTotalLimit();
     const isWritable = await fileUploadService.isWritable();
 
     const attachmentInfo = {

+ 4 - 4
apps/app/src/server/service/installer.ts

@@ -6,13 +6,13 @@ import ExtensibleCustomError from 'extensible-custom-error';
 import fs from 'graceful-fs';
 import mongoose from 'mongoose';
 
-import { IPage } from '~/interfaces/page';
-import { IUser } from '~/interfaces/user';
+import type { IPage } from '~/interfaces/page';
+import type { IUser } from '~/interfaces/user';
 import loggerFactory from '~/utils/logger';
 
 import { generateConfigsForInstalling } from '../models/config';
 
-import ConfigManager from './config-manager';
+import type { ConfigManager } from './config-manager';
 import SearchService from './search';
 
 const logger = loggerFactory('growi:service:installer');
@@ -146,7 +146,7 @@ export class InstallerService {
         name, username, email, password,
       } = firstAdminUserToSave;
       adminUser = await User.createUser(name, username, email, password, globalLang);
-      await adminUser.asyncMakeAdmin();
+      await adminUser.asyncGrantAdmin();
     }
     catch (err) {
       throw new FailedToCreateAdminUserError(err);

+ 8 - 7
apps/app/src/server/service/slack-integration.ts

@@ -5,20 +5,21 @@ import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder'
 import { InteractionPayloadAccessor } from '@growi/slack/dist/utils/interaction-payload-accessor';
 import type { RespondUtil } from '@growi/slack/dist/utils/respond-util-factory';
 import { generateWebClient } from '@growi/slack/dist/utils/webclient-factory';
-import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
-import { IncomingWebhookSendArguments } from '@slack/webhook';
+import { type ChatPostMessageArguments, WebClient } from '@slack/web-api';
+import type { IncomingWebhookSendArguments } from '@slack/webhook';
 import mongoose from 'mongoose';
 
 
 import loggerFactory from '~/utils/logger';
 
-import { EventActionsPermission } from '../interfaces/slack-integration/events';
+import type { EventActionsPermission } from '../interfaces/slack-integration/events';
 import S2sMessage from '../models/vo/s2s-message';
 import { SlackCommandHandlerError } from '../models/vo/slack-command-handler-error';
+import { slackLegacyUtilFactory } from '../util/slack-legacy';
 
-import ConfigManager from './config-manager';
-import { S2sMessagingService } from './s2s-messaging/base';
-import { S2sMessageHandlable } from './s2s-messaging/handlable';
+import type { ConfigManager } from './config-manager';
+import type { S2sMessagingService } from './s2s-messaging/base';
+import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 import { LinkSharedEventHandler } from './slack-event-handler/link-shared';
 
 const logger = loggerFactory('growi:service:SlackBotService');
@@ -230,7 +231,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   }
 
   private async postMessageWithLegacyUtil(messageArgs: ChatPostMessageArguments | IncomingWebhookSendArguments): Promise<void> {
-    const slackLegacyUtil = require('../util/slack-legacy')(this.crowi);
+    const slackLegacyUtil = slackLegacyUtilFactory(this.configManager);
 
     try {
       await slackLegacyUtil.postMessage(messageArgs);

+ 1 - 5
apps/app/src/server/util/mongoose-utils.ts

@@ -1,16 +1,12 @@
 import mongoose from 'mongoose';
 import type {
-  Model, Document, Schema, ConnectOptions,
+  Model, Document, ConnectOptions,
 } from 'mongoose';
 
 // suppress DeprecationWarning: current Server Discovery and Monitoring engine is deprecated, and will be removed in a future version
 type ConnectionOptionsExtend = {
   useUnifiedTopology: boolean
 }
-// No More Deprecation Warning Options
-// Removed useFindAndModify and useCreateIndex option
-// see: https://mongoosejs.com/docs/migrating_to_6.html#no-more-deprecation-warning-options
-export const initMongooseGlobalSettings = (): void => {};
 
 export const getMongoUri = (): string => {
   const { env } = process;

+ 0 - 64
apps/app/src/server/util/slack-legacy.js

@@ -1,64 +0,0 @@
-import { IncomingWebhook } from '@slack/webhook';
-import { WebClient } from '@slack/web-api';
-
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:util:slack-legacy');
-
-module.exports = function(crowi) {
-
-  const { configManager } = crowi;
-
-  const slackUtilLegacy = {};
-
-  const postWithIwh = async(messageObj) => {
-    const webhook = new IncomingWebhook(configManager.getConfig('notification', 'slack:incomingWebhookUrl'));
-    try {
-      await webhook.send(messageObj);
-    }
-    catch (error) {
-      logger.debug('Post error', error);
-      logger.debug('Sent data to slack is:', messageObj);
-      throw error;
-    }
-  };
-
-  const postWithWebApi = async(messageObj) => {
-    const client = new WebClient(configManager.getConfig('notification', 'slack:token'));
-    try {
-      await client.chat.postMessage(messageObj);
-    }
-    catch (error) {
-      logger.debug('Post error', error);
-      logger.debug('Sent data to slack is:', messageObj);
-      throw error;
-    }
-  };
-
-  slackUtilLegacy.postMessage = async(messageObj) => {
-    // when incoming Webhooks is prioritized
-    if (configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized')) {
-      if (configManager.getConfig('notification', 'slack:incomingWebhookUrl')) {
-        logger.debug('posting message with IncomingWebhook');
-        return postWithIwh(messageObj);
-      }
-      if (configManager.getConfig('notification', 'slack:token')) {
-        logger.debug('posting message with Web API');
-        return postWithWebApi(messageObj);
-      }
-    }
-    // else
-    else {
-      if (configManager.getConfig('notification', 'slack:token')) {
-        logger.debug('posting message with Web API');
-        return postWithWebApi(messageObj);
-      }
-      if (configManager.getConfig('notification', 'slack:incomingWebhookUrl')) {
-        logger.debug('posting message with IncomingWebhook');
-        return postWithIwh(messageObj);
-      }
-    }
-  };
-
-  return slackUtilLegacy;
-};

+ 67 - 0
apps/app/src/server/util/slack-legacy.ts

@@ -0,0 +1,67 @@
+import type { ChatPostMessageArguments } from '@slack/web-api';
+import { WebClient } from '@slack/web-api';
+import { IncomingWebhook, type IncomingWebhookSendArguments } from '@slack/webhook';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:util:slack-legacy');
+
+
+interface SlackLegacyUtil {
+  postMessage(messageObj: IncomingWebhookSendArguments | ChatPostMessageArguments): Promise<void>,
+}
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const slackLegacyUtilFactory = (configManager: any): SlackLegacyUtil => {
+
+  const postWithIwh = async(messageObj: IncomingWebhookSendArguments) => {
+    const webhook = new IncomingWebhook(configManager.getConfig('notification', 'slack:incomingWebhookUrl'));
+    try {
+      await webhook.send(messageObj);
+    }
+    catch (error) {
+      logger.debug('Post error', error);
+      logger.debug('Sent data to slack is:', messageObj);
+      throw error;
+    }
+  };
+
+  const postWithWebApi = async(messageObj?: ChatPostMessageArguments) => {
+    const client = new WebClient(configManager.getConfig('notification', 'slack:token'));
+    try {
+      await client.chat.postMessage(messageObj);
+    }
+    catch (error) {
+      logger.debug('Post error', error);
+      logger.debug('Sent data to slack is:', messageObj);
+      throw error;
+    }
+  };
+
+  return {
+    postMessage: async(messageObj) => {
+      // when incoming Webhooks is prioritized
+      if (configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized')) {
+        if (configManager.getConfig('notification', 'slack:incomingWebhookUrl')) {
+          logger.debug('posting message with IncomingWebhook');
+          return postWithIwh(messageObj as IncomingWebhookSendArguments);
+        }
+        if (configManager.getConfig('notification', 'slack:token')) {
+          logger.debug('posting message with Web API');
+          return postWithWebApi(messageObj as ChatPostMessageArguments);
+        }
+      }
+      // else
+      else {
+        if (configManager.getConfig('notification', 'slack:token')) {
+          logger.debug('posting message with Web API');
+          return postWithWebApi(messageObj as ChatPostMessageArguments);
+        }
+        if (configManager.getConfig('notification', 'slack:incomingWebhookUrl')) {
+          logger.debug('posting message with IncomingWebhook');
+          return postWithIwh(messageObj as IncomingWebhookSendArguments);
+        }
+      }
+    },
+  };
+};

+ 52 - 0
apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.spec.ts

@@ -0,0 +1,52 @@
+import { type HastNode, select } from 'hast-util-select';
+import parse from 'remark-parse';
+import rehype from 'remark-rehype';
+import { unified } from 'unified';
+
+import { pukiwikiLikeLinker } from '../remark-plugins/pukiwiki-like-linker';
+
+import { relativeLinksByPukiwikiLikeLinker } from './relative-links-by-pukiwiki-like-linker';
+
+describe('relativeLinksByPukiwikiLikeLinker', () => {
+
+  /* eslint-disable indent */
+  describe.each`
+    input                                   | expectedHref                        | expectedValue
+    ${'[[/page]]'}                          | ${'/page'}                          | ${'/page'}
+    ${'[[./page]]'}                         | ${'/user/admin/page'}               | ${'./page'}
+    ${'[[Title>./page]]'}                   | ${'/user/admin/page'}               | ${'Title'}
+    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}            | ${'Title'}
+    ${'[[/page?q=foo#header]]'}             | ${'/page?q=foo#header'}             | ${'/page?q=foo#header'}
+    ${'[[./page?q=foo#header]]'}            | ${'/user/admin/page?q=foo#header'}  | ${'./page?q=foo#header'}
+    ${'[[Title>./page?q=foo#header]]'}      | ${'/user/admin/page?q=foo#header'}  | ${'Title'}
+    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}            | ${'Title'}
+  `('should convert relative links correctly', ({ input, expectedHref, expectedValue }) => {
+  /* eslint-enable indent */
+
+    test(`when the input is '${input}'`, () => {
+      // setup:
+      const processor = unified()
+        .use(parse)
+        .use(pukiwikiLikeLinker)
+        .use(rehype)
+        .use(relativeLinksByPukiwikiLikeLinker, { pagePath: '/user/admin' });
+
+      // when:
+      const mdast = processor.parse(input);
+      const hast = processor.runSync(mdast) as HastNode;
+      const anchorElement = select('a', hast);
+
+      // then
+      expect(anchorElement).not.toBeNull();
+      expect(anchorElement?.properties).not.toBeNull();
+      expect((anchorElement?.properties?.className as string).startsWith('pukiwiki-like-linker')).toBeTruthy();
+      expect(anchorElement?.properties?.href).toEqual(expectedHref);
+
+      expect(anchorElement?.children[0]).not.toBeNull();
+      expect(anchorElement?.children[0].type).toEqual('text');
+      expect(anchorElement?.children[0].value).toEqual(expectedValue);
+
+    });
+  });
+
+});

+ 6 - 7
apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.ts

@@ -1,27 +1,26 @@
 import { pathUtils } from '@growi/core';
 import { selectAll } from 'hast-util-select';
-import { Plugin } from 'unified';
+import type { Plugin } from 'unified';
 
 import {
-  IAnchorsSelector, IHrefResolver, relativeLinks, RelativeLinksPluginParams,
+  relativeLinks,
+  type IAnchorsSelector, type IUrlResolver, type RelativeLinksPluginParams,
 } from './relative-links';
 
 const customAnchorsSelector: IAnchorsSelector = (node) => {
   return selectAll('a[href].pukiwiki-like-linker', node);
 };
 
-const customHrefResolver: IHrefResolver = (relativeHref, basePath) => {
+const customUrlResolver: IUrlResolver = (relativeHref, basePath) => {
   // generate relative pathname
   const baseUrl = new URL(pathUtils.addTrailingSlash(basePath), 'https://example.com');
-  const relativeUrl = new URL(relativeHref, baseUrl);
-
-  return relativeUrl.pathname;
+  return new URL(relativeHref, baseUrl);
 };
 
 export const relativeLinksByPukiwikiLikeLinker: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
   return relativeLinks.bind(this)({
     ...options,
     anchorsSelector: customAnchorsSelector,
-    hrefResolver: customHrefResolver,
+    urlResolver: customUrlResolver,
   });
 };

+ 79 - 0
apps/app/src/services/renderer/rehype-plugins/relative-links.spec.ts

@@ -0,0 +1,79 @@
+
+import { select, type HastNode } from 'hast-util-select';
+import parse from 'remark-parse';
+import remarkRehype from 'remark-rehype';
+import { unified } from 'unified';
+
+import { relativeLinks } from './relative-links';
+
+describe('relativeLinks', () => {
+
+  test('do nothing when the options does not have pagePath', () => {
+    // setup
+    const processor = unified()
+      .use(parse)
+      .use(remarkRehype)
+      .use(relativeLinks, {});
+
+    // when
+    const mdastTree = processor.parse('[link](/Sandbox)');
+    const hastTree = processor.runSync(mdastTree) as HastNode;
+
+    // then
+    const anchorElement = select('a', hastTree);
+    expect(anchorElement?.properties?.href).toBe('/Sandbox');
+  });
+
+  test.concurrent.each`
+    originalHref
+      ${'http://example.com/Sandbox'}
+      ${'#header'}
+    `('leaves the original href \'$originalHref\' as-is', ({ originalHref }) => {
+
+    // setup
+    const pagePath = '/foo/bar/baz';
+    const processor = unified()
+      .use(parse)
+      .use(remarkRehype)
+      .use(relativeLinks, { pagePath });
+
+    // when
+    const mdastTree = processor.parse(`[link](${originalHref})`);
+    const hastTree = processor.runSync(mdastTree) as HastNode;
+
+    // then
+    const anchorElement = select('a', hastTree);
+    expect(anchorElement?.properties?.href).toBe(originalHref);
+  });
+
+  test.concurrent.each`
+    originalHref                        | expectedHref
+      ${'/Sandbox'}                     | ${'/Sandbox'}
+      ${'/Sandbox?q=foo'}               | ${'/Sandbox?q=foo'}
+      ${'/Sandbox#header'}              | ${'/Sandbox#header'}
+      ${'/Sandbox?q=foo#header'}        | ${'/Sandbox?q=foo#header'}
+      ${'./Sandbox'}                    | ${'/foo/bar/Sandbox'}
+      ${'./Sandbox?q=foo'}              | ${'/foo/bar/Sandbox?q=foo'}
+      ${'./Sandbox#header'}             | ${'/foo/bar/Sandbox#header'}
+      ${'./Sandbox?q=foo#header'}       | ${'/foo/bar/Sandbox?q=foo#header'}
+    `('rewrites the original href \'$originalHref\' to \'$expectedHref\'', ({ originalHref, expectedHref }) => {
+
+    // setup
+    const pagePath = '/foo/bar/baz';
+    const processor = unified()
+      .use(parse)
+      .use(remarkRehype)
+      .use(relativeLinks, { pagePath });
+
+    // when
+    const mdastTree = processor.parse(`[link](${originalHref})`);
+    const hastTree = processor.runSync(mdastTree) as HastNode;
+
+    // then
+    const anchorElement = select('a', hastTree);
+    expect(anchorElement).not.toBeNull();
+    expect(anchorElement?.properties).not.toBeNull();
+    expect(anchorElement?.properties?.href).toBe(expectedHref);
+  });
+
+});

+ 16 - 12
apps/app/src/services/renderer/rehype-plugins/relative-links.ts

@@ -1,20 +1,26 @@
-import { selectAll, HastNode, Element } from 'hast-util-select';
+import assert from 'assert';
+
+import { selectAll, type HastNode, type Element } from 'hast-util-select';
 import isAbsolute from 'is-absolute-url';
-import { Plugin } from 'unified';
+import type { Plugin } from 'unified';
+
 
 export type IAnchorsSelector = (node: HastNode) => Element[];
-export type IHrefResolver = (relativeHref: string, basePath: string) => string;
+export type IUrlResolver = (relativeHref: string, basePath: string) => URL;
 
 const defaultAnchorsSelector: IAnchorsSelector = (node) => {
   return selectAll('a[href]', node);
 };
 
-const defaultHrefResolver: IHrefResolver = (relativeHref, basePath) => {
+const defaultUrlResolver: IUrlResolver = (relativeHref, basePath) => {
   // generate relative pathname
   const baseUrl = new URL(basePath, 'https://example.com');
-  const relativeUrl = new URL(relativeHref, baseUrl);
+  return new URL(relativeHref, baseUrl);
+};
 
-  return relativeUrl.pathname;
+const urlToHref = (url: URL): string => {
+  const { pathname, search, hash } = url;
+  return `${pathname}${search}${hash}`;
 };
 
 const isAnchorLink = (href: string): boolean => {
@@ -24,12 +30,12 @@ const isAnchorLink = (href: string): boolean => {
 export type RelativeLinksPluginParams = {
   pagePath?: string,
   anchorsSelector?: IAnchorsSelector,
-  hrefResolver?: IHrefResolver,
+  urlResolver?: IUrlResolver,
 }
 
 export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
   const anchorsSelector = options.anchorsSelector ?? defaultAnchorsSelector;
-  const hrefResolver = options.hrefResolver ?? defaultHrefResolver;
+  const urlResolver = options.urlResolver ?? defaultUrlResolver;
 
   return (tree) => {
     if (options.pagePath == null) {
@@ -40,16 +46,14 @@ export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {})
     const anchors = anchorsSelector(tree as HastNode);
 
     anchors.forEach((anchor) => {
-      if (anchor.properties == null) {
-        return;
-      }
+      assert(anchor.properties != null);
 
       const href = anchor.properties.href;
       if (href == null || typeof href !== 'string' || isAbsolute(href) || isAnchorLink(href)) {
         return;
       }
 
-      anchor.properties.href = hrefResolver(href, pagePath);
+      anchor.properties.href = urlToHref(urlResolver(href, pagePath));
     });
   };
 };

+ 43 - 0
apps/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.spec.ts

@@ -0,0 +1,43 @@
+import parse from 'remark-parse';
+import { unified } from 'unified';
+import { visit } from 'unist-util-visit';
+
+import { pukiwikiLikeLinker } from './pukiwiki-like-linker';
+
+describe('pukiwikiLikeLinker', () => {
+
+  describe.each`
+    input                                   | expectedHref                    | expectedValue
+    ${'[[/page]]'}                          | ${'/page'}                      | ${'/page'}
+    ${'[[./page]]'}                         | ${'./page'}                     | ${'./page'}
+    ${'[[Title>./page]]'}                   | ${'./page'}                     | ${'Title'}
+    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}        | ${'Title'}
+    ${'[[/page?q=foo#header]]'}             | ${'/page?q=foo#header'}         | ${'/page?q=foo#header'}
+    ${'[[./page?q=foo#header]]'}            | ${'./page?q=foo#header'}        | ${'./page?q=foo#header'}
+    ${'[[Title>./page?q=foo#header]]'}      | ${'./page?q=foo#header'}        | ${'Title'}
+    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}        | ${'Title'}
+  `('should parse correctly', ({ input, expectedHref, expectedValue }) => {
+
+    test(`when the input is '${input}'`, () => {
+      // setup:
+      const processor = unified()
+        .use(parse)
+        .use(pukiwikiLikeLinker);
+
+      // when:
+      const ast = processor.parse(input);
+
+      expect(ast).not.toBeNull();
+
+      visit(ast, 'wikiLink', (node: any) => {
+        expect(node.data.alias).toEqual(expectedValue);
+        expect(node.data.permalink).toEqual(expectedHref);
+        expect(node.data.hName).toEqual('a');
+        expect(node.data.hProperties.className.startsWith('pukiwiki-like-linker')).toBeTruthy();
+        expect(node.data.hProperties.href).toEqual(expectedHref);
+        expect(node.data.hChildren[0].value).toEqual(expectedValue);
+      });
+    });
+  });
+
+});

+ 2 - 2
apps/app/src/stores/bookmark-folder.ts

@@ -4,10 +4,10 @@ import useSWRImmutable from 'swr/immutable';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 
-export const useSWRxBookmarkFolderAndChild = (): SWRResponse<BookmarkFolderItems[], Error> => {
+export const useSWRxBookmarkFolderAndChild = (userId?: string): SWRResponse<BookmarkFolderItems[], Error> => {
 
   return useSWRImmutable(
-    '/bookmark-folder/list',
+    userId != null ? `/bookmark-folder/list/${userId}` : null,
     endpoint => apiv3Get(endpoint).then((response) => {
       return response.data.bookmarkFolderItems;
     }),

+ 2 - 7
apps/app/src/stores/bookmark.ts

@@ -1,4 +1,3 @@
-import { IUserHasId } from '@growi/core';
 import { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
@@ -7,8 +6,6 @@ import { IPageHasId } from '~/interfaces/page';
 import { apiv3Get } from '../client/util/apiv3-client';
 import { IBookmarkInfo } from '../interfaces/bookmark-info';
 
-import { useCurrentUser } from './context';
-
 export const useSWRBookmarkInfo = (pageId: string | null | undefined): SWRResponse<IBookmarkInfo, Error> => {
   return useSWRImmutable(
     pageId != null ? `/bookmarks/info?pageId=${pageId}` : null,
@@ -22,11 +19,9 @@ export const useSWRBookmarkInfo = (pageId: string | null | undefined): SWRRespon
   );
 };
 
-export const useSWRxCurrentUserBookmarks = (): SWRResponse<IPageHasId[], Error> => {
-  const { data: currentUser } = useCurrentUser();
-  const user = currentUser as IUserHasId;
+export const useSWRxUserBookmarks = (userId?: string): SWRResponse<IPageHasId[], Error> => {
   return useSWRImmutable(
-    currentUser != null ? `/bookmarks/${user._id}` : null,
+    userId != null ? `/bookmarks/${userId}` : null,
     endpoint => apiv3Get(endpoint).then((response) => {
       const { userRootBookmarks } = response.data;
       return userRootBookmarks.map((item) => {

+ 27 - 0
apps/app/src/stores/page-timeline.tsx

@@ -0,0 +1,27 @@
+
+import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { IPageHasId } from '~/interfaces/page';
+
+
+type PageTimelineResult = {
+  pages: IPageHasId[],
+  totalCount: number,
+  offset: number,
+}
+export const useSWRINFxPageTimeline = (path: string | undefined, limit: number) : SWRInfiniteResponse<PageTimelineResult, Error> => {
+  return useSWRInfinite(
+    (pageIndex, previousPageData) => {
+      if (previousPageData != null && previousPageData.pages.length === 0) return null;
+      if (path === undefined) return null;
+
+      return ['/pages/list', path, pageIndex + 1, limit];
+    },
+    ([endpoint, path, page, limit]) => apiv3Get<PageTimelineResult>(endpoint, { path, page, limit }).then(response => response.data),
+    {
+      revalidateFirstPage: false,
+      revalidateAll: false,
+    },
+  );
+};

+ 3 - 2
apps/app/test/unit/utils/page-delete-config.test.ts → apps/app/src/utils/page-delete-config.test.ts

@@ -1,5 +1,6 @@
-import { PageDeleteConfigValue } from '../../../src/interfaces/page-delete-config';
-import { validateDeleteConfigs } from '../../../src/utils/page-delete-config';
+import { PageDeleteConfigValue } from '../interfaces/page-delete-config';
+
+import { validateDeleteConfigs } from './page-delete-config';
 
 describe('validateDeleteConfigs utility function', () => {
   test('Should validate delete configs', () => {

+ 1 - 1
apps/app/test/unit/utils/to-array-from-csv.test.js → apps/app/src/utils/to-array-from-csv.spec.ts

@@ -1,4 +1,4 @@
-import { toArrayFromCsv } from '~/utils/to-array-from-csv';
+import { toArrayFromCsv } from './to-array-from-csv';
 
 describe('To array from csv', () => {
 

+ 16 - 0
apps/app/test/integration/.eslintrc.js

@@ -0,0 +1,16 @@
+module.exports = {
+  extends: [
+    'plugin:jest/recommended',
+  ],
+  env: {
+    'jest/globals': true,
+  },
+  plugins: ['jest'],
+  rules: {
+    'jest/no-done-callback': ['warn'],
+    'jest/no-standalone-expect': [
+      'error',
+      { additionalTestBlockFunctions: ['each.test'] },
+    ],
+  },
+};

+ 1 - 10
apps/app/test/integration/global-setup.js

@@ -1,13 +1,6 @@
-/** **********************************************************
- *                           Caution
- *
- * Module aliases by compilerOptions.paths in tsconfig.json
- * are NOT available in setup scripts
- *********************************************************** */
-
 import mongoose from 'mongoose';
 
-import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 
 // check env
 if (process.env.NODE_ENV !== 'test') {
@@ -15,8 +8,6 @@ if (process.env.NODE_ENV !== 'test') {
 }
 
 module.exports = async() => {
-  initMongooseGlobalSettings();
-
   mongoose.connect(getMongoUri(), mongoOptions);
 
   // drop database

+ 2 - 2
apps/app/test/integration/migrations/20210913153942-migrate-slack-app-integration-schema.test.ts

@@ -1,7 +1,7 @@
-import mongoose from 'mongoose';
 import { Collection } from 'mongodb';
+import mongoose from 'mongoose';
 
-const migrate = require('~/migrations/20210913153942-migrate-slack-app-integration-schema');
+import migrate from '~/migrations/20210913153942-migrate-slack-app-integration-schema';
 
 describe('migrate-slack-app-integration-schema', () => {
 

+ 2 - 2
apps/app/test/integration/service/questionnaire-cron.test.ts

@@ -1,3 +1,5 @@
+// eslint-disable-next-line no-restricted-imports
+import axios from 'axios';
 import mongoose from 'mongoose';
 
 import { IProactiveQuestionnaireAnswer } from '../../../src/features/questionnaire/interfaces/proactive-questionnaire-answer';
@@ -9,8 +11,6 @@ import QuestionnaireAnswerStatus from '../../../src/features/questionnaire/serve
 import QuestionnaireOrder from '../../../src/features/questionnaire/server/models/questionnaire-order';
 import { getInstance } from '../setup-crowi';
 
-const axios = require('axios').default;
-
 const spyAxiosGet = jest.spyOn<typeof axios, 'get'>(
   axios,
   'get',

+ 1 - 2
apps/app/test/integration/setup.js

@@ -8,14 +8,13 @@
 const gc = require('expose-gc/function');
 const mongoose = require('mongoose');
 
-const { initMongooseGlobalSettings, getMongoUri, mongoOptions } = require('~/server/util/mongoose-utils');
+const { getMongoUri, mongoOptions } = require('~/server/util/mongoose-utils');
 
 mongoose.Promise = global.Promise;
 
 jest.setTimeout(30000); // default 5000
 
 beforeAll(async() => {
-  initMongooseGlobalSettings();
   await mongoose.connect(getMongoUri(), mongoOptions);
 });
 

+ 0 - 17
apps/app/test/integration/utils/slack-legacy.test.js

@@ -1,17 +0,0 @@
-const { getInstance } = require('../setup-crowi');
-
-describe('Slack Util', () => {
-
-  let crowi;
-  let slackLegacyUtil;
-
-  beforeEach(async() => {
-    crowi = await getInstance();
-    slackLegacyUtil = require('~/server/util/slack-legacy')(crowi);
-  });
-
-  test('postMessage method exists', () => {
-    expect(slackLegacyUtil.postMessage).toBeInstanceOf(Function);
-  });
-
-});

+ 0 - 71
apps/app/test/unit/migrate-mongo-config.test.js

@@ -1,71 +0,0 @@
-describe('config/migrate-mongo-config.js', () => {
-
-  beforeEach(async() => {
-    jest.resetModules();
-  });
-
-  test('throws an error when MIGRATIONS_DIR is not set', () => {
-
-    const initMongooseGlobalSettingsMock = jest.fn();
-
-    // mock for mongoose-utils
-    jest.doMock('../../src/server/util/mongoose-utils', () => {
-      return {
-        initMongooseGlobalSettings: initMongooseGlobalSettingsMock,
-      };
-    });
-
-    const requireConfig = () => {
-      require('../../config/migrate-mongo-config');
-    };
-
-    expect(requireConfig).toThrow('An env var MIGRATIONS_DIR must be set.');
-
-    jest.dontMock('../../src/server/util/mongoose-utils');
-
-    expect(initMongooseGlobalSettingsMock).not.toHaveBeenCalled();
-  });
-
-  /* eslint-disable indent */
-  describe.each`
-    MONGO_URI                                         | expectedDbName
-    ${'mongodb://example.com/growi'}                  | ${'growi'}
-    ${'mongodb://user:pass@example.com/growi'}        | ${'growi'}
-    ${'mongodb://example.com/growi?replicaSet=mySet'} | ${'growi'}
-  `('returns', ({ MONGO_URI, expectedDbName }) => {
-
-    beforeEach(async() => {
-      process.env.MIGRATIONS_DIR = 'testdir/migrations';
-    });
-
-    test(`when 'MONGO_URI' is '${MONGO_URI}`, () => {
-
-      const initMongooseGlobalSettingsMock = jest.fn();
-      const mongoOptionsMock = jest.fn();
-
-      // mock for mongoose-utils
-      jest.doMock('../../src/server/util/mongoose-utils', () => {
-        return {
-          initMongooseGlobalSettings: initMongooseGlobalSettingsMock,
-          getMongoUri: () => {
-            return MONGO_URI;
-          },
-          mongoOptions: mongoOptionsMock,
-        };
-      });
-
-      const { mongodb, migrationsDir, changelogCollectionName } = require('../../config/migrate-mongo-config');
-
-      jest.dontMock('../../src/server/util/mongoose-utils');
-
-      expect(initMongooseGlobalSettingsMock).toHaveBeenCalledTimes(1);
-      expect(mongodb.url).toBe(MONGO_URI);
-      expect(mongodb.databaseName).toBe(expectedDbName);
-      expect(mongodb.options).toBe(mongoOptionsMock);
-      expect(migrationsDir).toBe('testdir/migrations');
-      expect(changelogCollectionName).toBe('migrations');
-    });
-  });
-  /* eslint-enable indent */
-
-});

+ 0 - 92
apps/app/test/unit/services/renderer/pukiwiki-like-linker.test.ts

@@ -1,92 +0,0 @@
-import { HastNode, selectAll } from 'hast-util-select';
-import parse from 'remark-parse';
-import rehype from 'remark-rehype';
-import { unified } from 'unified';
-import { visit } from 'unist-util-visit';
-
-import { relativeLinksByPukiwikiLikeLinker } from '../../../../src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker';
-import { pukiwikiLikeLinker } from '../../../../src/services/renderer/remark-plugins/pukiwiki-like-linker';
-
-describe('pukiwikiLikeLinker', () => {
-
-  /* eslint-disable indent */
-  describe.each`
-    input                                   | expectedHref                | expectedValue
-    ${'[[/page]]'}                          | ${'/page'}                  | ${'/page'}
-    ${'[[./page]]'}                         | ${'./page'}                 | ${'./page'}
-    ${'[[Title>./page]]'}                   | ${'./page'}                 | ${'Title'}
-    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}    | ${'Title'}
-  `('should parse correctly', ({ input, expectedHref, expectedValue }) => {
-  /* eslint-enable indent */
-
-    test(`when the input is '${input}'`, () => {
-      // setup:
-      const processor = unified()
-        .use(parse)
-        .use(pukiwikiLikeLinker);
-
-      // when:
-      const ast = processor.parse(input);
-
-      expect(ast).not.toBeNull();
-
-      visit(ast, 'wikiLink', (node: any) => {
-        expect(node.data.alias).toEqual(expectedValue);
-        expect(node.data.permalink).toEqual(expectedHref);
-        expect(node.data.hName).toEqual('a');
-        expect(node.data.hProperties.className.startsWith('pukiwiki-like-linker')).toBeTruthy();
-        expect(node.data.hProperties.href).toEqual(expectedHref);
-        expect(node.data.hChildren[0].value).toEqual(expectedValue);
-      });
-
-    });
-  });
-
-});
-
-
-describe('relativeLinksByPukiwikiLikeLinker', () => {
-
-  /* eslint-disable indent */
-  describe.each`
-    input                                   | expectedHref                | expectedValue
-    ${'[[/page]]'}                          | ${'/page'}                  | ${'/page'}
-    ${'[[./page]]'}                         | ${'/user/admin/page'}       | ${'./page'}
-    ${'[[Title>./page]]'}                   | ${'/user/admin/page'}       | ${'Title'}
-    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}    | ${'Title'}
-  `('should convert relative links correctly', ({ input, expectedHref, expectedValue }) => {
-  /* eslint-enable indent */
-
-    test(`when the input is '${input}'`, () => {
-      // setup:
-      const processor = unified()
-        .use(parse)
-        .use(pukiwikiLikeLinker)
-        .use(rehype)
-        .use(relativeLinksByPukiwikiLikeLinker, { pagePath: '/user/admin' });
-
-      // when:
-      const mdast = processor.parse(input);
-      const hast = processor.runSync(mdast);
-
-      expect(hast).not.toBeNull();
-      expect((hast as any).children[0].type).toEqual('element');
-
-      const anchors = selectAll('a', hast as HastNode);
-
-      expect(anchors.length).toEqual(1);
-
-      const anchor = anchors[0];
-
-      expect(anchor.tagName).toEqual('a');
-      expect((anchor.properties as any).className.startsWith('pukiwiki-like-linker')).toBeTruthy();
-      expect(anchor.properties?.href).toEqual(expectedHref);
-
-      expect(anchor.children[0]).not.toBeNull();
-      expect(anchor.children[0].type).toEqual('text');
-      expect(anchor.children[0].value).toEqual(expectedValue);
-
-    });
-  });
-
-});

+ 3 - 0
apps/app/tsconfig.json

@@ -6,6 +6,9 @@
 
     "jsx": "preserve",
     "resolveJsonModule": true,
+    "types": [
+      "vitest/globals"
+    ],
 
     "baseUrl": ".",
     "paths": {

+ 14 - 0
apps/app/vitest.config.ts

@@ -0,0 +1,14 @@
+import tsconfigPaths from 'vite-tsconfig-paths';
+import { defineProject } from 'vitest/config';
+
+export default defineProject({
+  plugins: [
+    tsconfigPaths(),
+  ],
+  test: {
+    environment: 'node',
+    exclude: ['**/test/**'],
+    clearMocks: true,
+    globals: true,
+  },
+});

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

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

+ 23 - 19
bin/data-migrations/v6/README.md → bin/data-migrations/README.md

@@ -1,4 +1,4 @@
-# Migration to v6 from v5
+# Migration of page body
 
 > **Warning**
 > **Migration in this way is applied only to the latest revision. Past revisions are not applied.**
@@ -6,7 +6,7 @@
 ## Usage
 ```
 git clone https://github.com/weseek/growi
-cd growi/bin/data-migrations/v6
+cd growi/bin/data-migrations
 
 NETWORK=growi_devcontainer_default \
 MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi \
@@ -14,9 +14,9 @@ docker run --rm \
   --network $NETWORK \
   -v "$(pwd)"/src:/opt \
   -w /opt \
-  -e MIGRATION_TYPE=v6 \
+  -e MIGRATION_MODULE=v60x \
   mongo:6.0 \
-  /bin/mongosh $MONGO_URI migration.js
+  /bin/mongosh $MONGO_URI index.js
 ```
 
 ## Variables
@@ -30,20 +30,22 @@ docker run --rm \
 
 | Variable              | Description                                                                    | Default |
 | --------------------- | ------------------------------------------------------------------------------ | ------- |
-| MIGRATION_TYPE     | Migrated notation                                                        | -       |
+| MIGRATION_MODULE     | Specify migration module                                                       | -       |
 
-The value of `MIGRATION_TYPE` is one of the following.
-- `v6-drawio`: Migration for Draw.io notation only([
+The value of `MIGRATION_MODULE` is one of the following.
+- `v60x/drawio`: Migration for Draw.io notation only([
 reference](https://docs.growi.org/ja/admin-guide/upgrading/60x.html#%E4%BB%95%E6%A7%98%E5%A4%89%E6%9B%B4-draw-io-diagrams-net-%E8%A8%98%E6%B3%95))
-- `v6-plantuml`: Migration for PlantUML notation only([
+- `v60x/plantuml`: Migration for PlantUML notation only([
 reference](https://docs.growi.org/ja/admin-guide/upgrading/60x.html#%E4%BB%95%E6%A7%98%E5%A4%89%E6%9B%B4-plantuml-%E8%A8%98%E6%B3%95))
-- `v6-tsv`: Migration for table notation by TSV only([
+- `v60x/tsv`: Migration for table notation by TSV only([
 reference](https://docs.growi.org/ja/admin-guide/upgrading/60x.html#%E4%BB%95%E6%A7%98%E5%A4%89%E6%9B%B4-csv-tsv-%E3%81%AB%E3%82%88%E3%82%8B%E3%83%86%E3%83%BC%E3%83%95%E3%82%99%E3%83%AB%E6%8F%8F%E7%94%BB%E8%A8%98%E6%B3%95))
-- `v6-csv`: Migration for table notation by CSV only([
+- `v60x/csv`: Migration for table notation by CSV only([
 reference](https://docs.growi.org/ja/admin-guide/upgrading/60x.html#%E4%BB%95%E6%A7%98%E5%A4%89%E6%9B%B4-csv-tsv-%E3%81%AB%E3%82%88%E3%82%8B%E3%83%86%E3%83%BC%E3%83%95%E3%82%99%E3%83%AB%E6%8F%8F%E7%94%BB%E8%A8%98%E6%B3%95))
-- `v6-bracketlink`: Migration for only page links within GROWI([
+- `v60x/bracketlink`: Migration for only page links within GROWI([
 reference](https://docs.growi.org/ja/admin-guide/upgrading/60x.html#%E6%9C%AA%E5%AE%9F%E8%A3%85-%E5%BB%83%E6%AD%A2%E6%A4%9C%E8%A8%8E%E4%B8%AD-growi-%E7%8B%AC%E8%87%AA%E8%A8%98%E6%B3%95%E3%81%AE%E3%83%98%E3%82%9A%E3%83%BC%E3%82%B7%E3%82%99%E3%83%AA%E3%83%B3%E3%82%AF))
-- `v6`: Migration for all the above notations
+- `v60x` or `v60x/index`: Migration for all notations in v6.0.x series
+- `v61x/mdcont`: Migration for mdcont notation only([reference](https://docs.growi.org/ja/admin-guide/upgrading/61x.html#%E4%BB%95%E6%A7%98%E5%A4%89%E6%9B%B4-%E3%82%A2%E3%83%B3%E3%82%AB%E3%83%BC%E3%83%AA%E3%83%B3%E3%82%AF%E3%81%AB%E8%87%AA%E5%8B%95%E4%BB%98%E4%B8%8E%E3%81%95%E3%82%8C%E3%82%8B-mdcont-%E3%83%95%E3%82%9A%E3%83%AC%E3%83%95%E3%82%A3%E3%82%AF%E3%82%B9%E3%81%AE%E5%BB%83%E6%AD%A2))
+- `v61x` or `v61x/index`: Migration for all notations in v6.1.x series
 - `custom`: You can define your own processors and apply them to `revision` (see "Advanced" below for details)
 
 ### Optional
@@ -57,21 +59,23 @@ reference](https://docs.growi.org/ja/admin-guide/upgrading/60x.html#%E6%9C%AA%E5
 
 ## Advanced
 
-By creating a function in `growi/bin/data-migrations/v6/src/processor.js` that replaces a specific regular expression, you can replace all specific strings in the latest revisions for all pages.
+By creating a function in `growi/bin/data-migrations/src/migrations/custom.js` that replaces a specific regular expression, you can replace all specific strings in the latest revisions for all pages.
 
 The following function replaces the string `foo` with the string `bar`.
 
 ``` javascript
-function customProcessor(body) {
+module.exports = [
+  (body) => {
   var fooRegExp = /foo/g; // foo regex
   return body.replace(fooRegExp, 'bar'); // replace to bar
-}
+  },
+];
 ```
 
-By passing `custom` in the environment variable `MIGRATION_TYPE` and executing it, you can apply the `customProcessor` to all the latest `revisions`.
+By passing `custom` in the environment variable `MIGRATION_MODULE` and executing it, you can apply the `custom.js` to all the latest `revisions`.
 ```
 git clone https://github.com/weseek/growi
-cd growi/bin/data-migrations/v6
+cd growi/bin/data-migrations
 
 NETWORK=growi_devcontainer_default \
 MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi \
@@ -79,7 +83,7 @@ docker run --rm \
   --network $NETWORK \
   -v "$(pwd)"/src:/opt \
   -w /opt \
-  -e MIGRATION_TYPE=custom \
+  -e MIGRATION_MODULE=custom \
   mongo:6.0 \
-  /bin/mongosh $MONGO_URI migration.js
+  /bin/mongosh $MONGO_URI index.js
 ```

+ 13 - 17
bin/data-migrations/v6/src/migration.js → bin/data-migrations/src/index.js

@@ -5,35 +5,30 @@
 var pagesCollection = db.getCollection('pages');
 var revisionsCollection = db.getCollection('revisions');
 
-var getProcessorArray = require('./processor.js');
+var batchSize = process.env.BATCH_SIZE ?? 100; // default 100 revisions in 1 bulkwrite
+var batchSizeInterval = process.env.BATCH_INTERVAL ?? 3000; // default 3 sec
 
-var migrationType = process.env.MIGRATION_TYPE;
-var processors = getProcessorArray(migrationType);
+var migrationModule = process.env.MIGRATION_MODULE;
 
-var operations = [];
+var migrationModules = require(`./migrations/${migrationModule}`);
 
-var batchSize = process.env.BATCH_SIZE ?? 100; // default 100 revisions in 1 bulkwrite
-var batchSizeInterval = process.env.BATCH_INTERVAL ?? 3000; // default 3 sec
+if (migrationModules.length === 0) {
+  throw Error('No valid migrationModules found. Please enter a valid environment variable');
+}
 
-// ===========================================
-// replace method with processors
-// ===========================================
-function replaceLatestRevisions(body, processors) {
+function replaceLatestRevisions(body, migrationModules) {
   var replacedBody = body;
-  processors.forEach((processor) => {
-    replacedBody = processor(replacedBody);
+  migrationModules.forEach((migrationModule) => {
+    replacedBody = migrationModule(replacedBody);
   });
   return replacedBody;
 }
 
-if (processors.length === 0) {
-  throw Error('No valid processors found. Please enter a valid environment variable');
-}
-
+var operations = [];
 pagesCollection.find({}).forEach((doc) => {
   if (doc.revision) {
     var revision = revisionsCollection.findOne({ _id: doc.revision });
-    var replacedBody = replaceLatestRevisions(revision.body, [...processors]);
+    var replacedBody = replaceLatestRevisions(revision.body, [...migrationModules]);
     var operation = {
       updateOne: {
         filter: { _id: revision._id },
@@ -54,4 +49,5 @@ pagesCollection.find({}).forEach((doc) => {
   }
 });
 revisionsCollection.bulkWrite(operations);
+
 print('migration complete!');

+ 8 - 0
bin/data-migrations/src/migrations/custom.js

@@ -0,0 +1,8 @@
+module.exports = [
+  (body) => {
+    // processor for MIGRATION_MODULE=custom
+    // ADD YOUR PROCESS HERE!
+    // https://github.com/weseek/growi/discussions/7180
+    return body;
+  },
+];

+ 8 - 0
bin/data-migrations/src/migrations/v60x/bracketlink.js

@@ -0,0 +1,8 @@
+module.exports = [
+  (body) => {
+    // https://regex101.com/r/btZ4hc/1
+    // eslint-disable-next-line regex/invalid
+    const oldBracketLinkRegExp = /(?<!\[)\[{1}(\/.*?)\]{1}(?!\])/g; // Page Link old format
+    return body.replace(oldBracketLinkRegExp, '[[$1]]');
+  },
+];

+ 6 - 0
bin/data-migrations/src/migrations/v60x/csv.js

@@ -0,0 +1,6 @@
+module.exports = [
+  (body) => {
+    const oldCsvTableRegExp = /::: csv(-h)?\n([\s\S]*?)\n:::/g; // CSV old format
+    return body.replace(oldCsvTableRegExp, '``` csv$1\n$2\n```');
+  },
+];

+ 6 - 0
bin/data-migrations/src/migrations/v60x/drawio.js

@@ -0,0 +1,6 @@
+module.exports = [
+  (body) => {
+    const oldDrawioRegExp = /:::\s?drawio\n(.+?)\n:::/g; // drawio old format
+    return body.replace(oldDrawioRegExp, '``` drawio\n$1\n```');
+  },
+];

Некоторые файлы не были показаны из-за большого количества измененных файлов