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

Merge branch 'master' into imprv/88266-122975-normal-browser-transition

reiji-h 2 лет назад
Родитель
Сommit
bdc8335962
100 измененных файлов с 1636 добавлено и 1136 удалено
  1. 0 11
      .eslintrc.js
  2. 1 2
      .github/ISSUE_TEMPLATE/bug-report.md
  3. 67 48
      CHANGELOG.md
  4. 9 0
      apps/app/.env.test
  5. 0 11
      apps/app/.eslintrc.js
  6. 1 4
      apps/app/config/migrate-mongo-config.js
  7. 71 0
      apps/app/config/migrate-mongo-config.spec.ts
  8. 0 20
      apps/app/jest.config.js
  9. 16 12
      apps/app/package.json
  10. 8 8
      apps/app/public/static/locales/en_US/admin.json
  11. 1 7
      apps/app/public/static/locales/en_US/translation.json
  12. 7 7
      apps/app/public/static/locales/ja_JP/admin.json
  13. 1 7
      apps/app/public/static/locales/ja_JP/translation.json
  14. 7 7
      apps/app/public/static/locales/zh_CN/admin.json
  15. 1 7
      apps/app/public/static/locales/zh_CN/translation.json
  16. 6 6
      apps/app/src/client/services/AdminUsersContainer.js
  17. 9 9
      apps/app/src/components/Admin/Users/GrantAdminButton.tsx
  18. 15 15
      apps/app/src/components/Admin/Users/RevokeAdminButton.tsx
  19. 13 13
      apps/app/src/components/Admin/Users/RevokeAdminMenuItem.tsx
  20. 3 3
      apps/app/src/components/Admin/Users/UserMenu.tsx
  21. 4 3
      apps/app/src/components/InstallerForm.tsx
  22. 0 6
      apps/app/src/components/Layout/NoLoginLayout.module.scss
  23. 6 0
      apps/app/src/components/LoginForm.module.scss
  24. 1 1
      apps/app/src/components/LoginForm.tsx
  25. 1 1
      apps/app/src/components/PageList/PageList.tsx
  26. 21 31
      apps/app/src/components/PageTimeline.tsx
  27. 27 8
      apps/app/src/components/TemplateModal/index.tsx
  28. 101 0
      apps/app/src/components/TemplateModal/use-formatter.spec.tsx
  29. 48 0
      apps/app/src/components/TemplateModal/use-formatter.tsx
  30. 6 6
      apps/app/src/interfaces/activity.ts
  31. 1 3
      apps/app/src/server/console.js
  32. 3 1
      apps/app/src/server/crowi/express-init.js
  33. 5 9
      apps/app/src/server/crowi/index.js
  34. 3 3
      apps/app/src/server/middlewares/exclude-read-only-user.spec.ts
  35. 18 30
      apps/app/src/server/middlewares/safe-redirect.spec.ts
  36. 6 4
      apps/app/src/server/middlewares/safe-redirect.ts
  37. 2 1
      apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts
  38. 9 9
      apps/app/src/server/models/user.js
  39. 20 20
      apps/app/src/server/routes/apiv3/users.js
  40. 51 38
      apps/app/src/server/service/acl.integ.ts
  41. 19 27
      apps/app/src/server/service/acl.ts
  42. 23 20
      apps/app/src/server/service/config-manager.spec.ts
  43. 75 85
      apps/app/src/server/service/config-manager.ts
  44. 2 2
      apps/app/src/server/service/customize.ts
  45. 4 3
      apps/app/src/server/service/file-uploader-switch.ts
  46. 47 12
      apps/app/src/server/service/file-uploader/aws.ts
  47. 151 0
      apps/app/src/server/service/file-uploader/file-uploader.ts
  48. 3 2
      apps/app/src/server/service/file-uploader/gcs.js
  49. 49 12
      apps/app/src/server/service/file-uploader/gridfs.ts
  50. 3 2
      apps/app/src/server/service/file-uploader/local.js
  51. 3 2
      apps/app/src/server/service/file-uploader/none.js
  52. 0 123
      apps/app/src/server/service/file-uploader/uploader.js
  53. 1 1
      apps/app/src/server/service/g2g-transfer.ts
  54. 4 4
      apps/app/src/server/service/installer.ts
  55. 8 7
      apps/app/src/server/service/slack-integration.ts
  56. 1 5
      apps/app/src/server/util/mongoose-utils.ts
  57. 0 64
      apps/app/src/server/util/slack-legacy.js
  58. 67 0
      apps/app/src/server/util/slack-legacy.ts
  59. 52 0
      apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.spec.ts
  60. 6 7
      apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.ts
  61. 79 0
      apps/app/src/services/renderer/rehype-plugins/relative-links.spec.ts
  62. 16 12
      apps/app/src/services/renderer/rehype-plugins/relative-links.ts
  63. 43 0
      apps/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.spec.ts
  64. 9 5
      apps/app/src/stores/modal.tsx
  65. 27 0
      apps/app/src/stores/page-timeline.tsx
  66. 100 20
      apps/app/src/stores/template.tsx
  67. 3 2
      apps/app/src/utils/page-delete-config.test.ts
  68. 1 1
      apps/app/src/utils/to-array-from-csv.spec.ts
  69. 5 0
      apps/app/test-with-vite/.eslintrc.js
  70. 26 0
      apps/app/test-with-vite/setup/mongoms.ts
  71. 11 0
      apps/app/test-with-vite/tsconfig.json
  72. 16 0
      apps/app/test/integration/.eslintrc.js
  73. 1 10
      apps/app/test/integration/global-setup.js
  74. 2 2
      apps/app/test/integration/migrations/20210913153942-migrate-slack-app-integration-schema.test.ts
  75. 0 19
      apps/app/test/integration/models/config.test.js
  76. 2 2
      apps/app/test/integration/service/questionnaire-cron.test.ts
  77. 1 2
      apps/app/test/integration/setup.js
  78. 0 17
      apps/app/test/integration/utils/slack-legacy.test.js
  79. 0 71
      apps/app/test/unit/migrate-mongo-config.test.js
  80. 0 92
      apps/app/test/unit/services/renderer/pukiwiki-like-linker.test.ts
  81. 4 1
      apps/app/tsconfig.json
  82. 22 0
      apps/app/vitest.config.integ.ts
  83. 16 0
      apps/app/vitest.config.ts
  84. 0 7
      apps/app/vitest.config.unit.ts
  85. 2 2
      apps/slackbot-proxy/package.json
  86. 23 19
      bin/data-migrations/README.md
  87. 20 17
      bin/data-migrations/src/index.js
  88. 15 0
      bin/data-migrations/src/migrations/custom.js
  89. 15 0
      bin/data-migrations/src/migrations/v60x/bracketlink.js
  90. 13 0
      bin/data-migrations/src/migrations/v60x/csv.js
  91. 13 0
      bin/data-migrations/src/migrations/v60x/drawio.js
  92. 6 0
      bin/data-migrations/src/migrations/v60x/index.js
  93. 13 0
      bin/data-migrations/src/migrations/v60x/plantuml.js
  94. 13 0
      bin/data-migrations/src/migrations/v60x/tsv.js
  95. 3 0
      bin/data-migrations/src/migrations/v61x/index.js
  96. 13 0
      bin/data-migrations/src/migrations/v61x/mdcont.js
  97. 2 0
      bin/data-migrations/src/types.d.ts
  98. 0 83
      bin/data-migrations/v6/src/processor.js
  99. 9 3
      package.json
  100. 5 0
      packages/core/.eslintrc.js

+ 0 - 11
.eslintrc.js

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

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

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

+ 67 - 48
CHANGELOG.md

@@ -1,9 +1,28 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.1.1...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.1.2...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v6.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
 ## [v6.1.1](https://github.com/weseek/growi/compare/v6.1.0...v6.1.1) - 2023-05-24
 
 
 ### 🐛 Bug Fixes
 ### 🐛 Bug Fixes
@@ -19,64 +38,64 @@
 
 
 ### BREAKING CHANGES
 ### 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
+- 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)
 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
 ### 💎 Features
 
 
-* feat: Add read-only user feature (#7648) @jam411
-* feat: Support Mermaid (move into the feature dierctory) (#7647) @miya
-* feat: Fix APP\_SITE\_URL with an environment variable (#7646) @yuki-takei
-* feat: Support Mermaid (#7645) @miya
-* feat: Support Elasticsearch v8 (#7623) @miya
-* feat: Elasticsearchv8 module (#7623) @miya
-* feat: Bookmarks folder and sidebar menu (#7450) @mudana-grune
-* feat: GROWI Questionnaire (#7316) @hakumizuki
-* feat: Revive attachment-refs with remark (#7597) @arafubeatbox
+- feat: Add read-only user feature (#7648) @jam411
+- feat: Support Mermaid (move into the feature dierctory) (#7647) @miya
+- feat: Fix APP_SITE_URL with an environment variable (#7646) @yuki-takei
+- feat: Support Mermaid (#7645) @miya
+- feat: Support Elasticsearch v8 (#7623) @miya
+- feat: Elasticsearchv8 module (#7623) @miya
+- feat: Bookmarks folder and sidebar menu (#7450) @mudana-grune
+- feat: GROWI Questionnaire (#7316) @hakumizuki
+- feat: Revive attachment-refs with remark (#7597) @arafubeatbox
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement
 
 
-* imprv: Font size (#7663) @yuki-takei
-* imprv: Admin user can use `reset-password` without email settings (#7650) @jam411
-* 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: GFM table performance (#7619) @yuki-takei
-* imprv: Show unsaved warning when comment not posted (#7603) @arafubeatbox
-* imprv: Suppress UI Flickering for dropdowns (#7608) @jam411
-* imprv: Allow registering without GROWI email settings for ID/Password authentication's restricted registration (#7591) @jam411
-* imprv: Enable browsing video (for v6.1.0) (#7589) @yuki-takei
-* imprv: Show a spinner into the save button while the saving process (#7579) @yuki-takei
-* imprv: Inject PlantUML URI with config-loader (#7577) @yuki-takei
-* imprv: Loading draw.io (diagrams.net) resources (#7575) @yuki-takei
-
-### 🐛 Bug Fixes
-
-* fix: The environment variable for disabling link sharing (#7652) @yuki-takei
-* 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
-* 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
-* fix: Deleted descendant pages do not appear in search results (#7583) @miya
-* fix: Show lsx page list in trash page correctly (#7582) @yukendev
-* fix: Uncaught type error by `sticky-event` (#7566) @mudana-grune
+- imprv: Font size (#7663) @yuki-takei
+- imprv: Admin user can use `reset-password` without email settings (#7650) @jam411
+- 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: GFM table performance (#7619) @yuki-takei
+- imprv: Show unsaved warning when comment not posted (#7603) @arafubeatbox
+- imprv: Suppress UI Flickering for dropdowns (#7608) @jam411
+- imprv: Allow registering without GROWI email settings for ID/Password authentication's restricted registration (#7591) @jam411
+- imprv: Enable browsing video (for v6.1.0) (#7589) @yuki-takei
+- imprv: Show a spinner into the save button while the saving process (#7579) @yuki-takei
+- imprv: Inject PlantUML URI with config-loader (#7577) @yuki-takei
+- imprv: Loading draw.io (diagrams.net) resources (#7575) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: The environment variable for disabling link sharing (#7652) @yuki-takei
+- 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
+- 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
+- fix: Deleted descendant pages do not appear in search results (#7583) @miya
+- fix: Show lsx page list in trash page correctly (#7582) @yukendev
+- fix: Uncaught type error by `sticky-event` (#7566) @mudana-grune
 
 
 ### 🧰 Maintenance
 ### 🧰 Maintenance
 
 
-* support: mongoose update (#7659) @jam411
-* support: Elasticsearch8 (#7592) @miya
-* support: Replaced by IAttachmentHasId (#7629) @reiji-h
-* support: Dedupe packages (#7590) @yuki-takei
-* support: Typescriptize CustomNav (#7584) @yuki-takei
-* support: Replaced by IAttachmentHasId (#7629) @reiji-h
-* support: Migrate to Turborepo (#7417) @yuki-takei
+- support: mongoose update (#7659) @jam411
+- support: Elasticsearch8 (#7592) @miya
+- support: Replaced by IAttachmentHasId (#7629) @reiji-h
+- support: Dedupe packages (#7590) @yuki-takei
+- support: Typescriptize CustomNav (#7584) @yuki-takei
+- support: Replaced by IAttachmentHasId (#7629) @reiji-h
+- 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
 ## [v6.0.15](https://github.com/weseek/growi/compare/v6.0.14...v6.0.15) - 2023-04-10
 
 

+ 9 - 0
apps/app/.env.test

@@ -0,0 +1,9 @@
+##
+## Handled by vite
+## https://vitejs.dev/guide/env-and-mode.html
+##
+## > To prevent accidentally leaking env variables to the client, only variables prefixed with
+## > VITE_ are exposed to your Vite-processed code. e.g. for the following env variables:
+##
+VITE_MONGOMS_VERSION="6.0.6"
+# VITE_MONGOMS_DEBUG=1

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

@@ -5,16 +5,6 @@ module.exports = {
   plugins: [
   plugins: [
     'regex',
     'regex',
   ],
   ],
-  env: {
-    jquery: true,
-  },
-  globals: {
-    $: true,
-    jquery: true,
-    hljs: true,
-    ScrollPosStyler: true,
-    window: true,
-  },
   settings: {
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     // resolve path aliases by eslint-import-resolver-typescript
     'import/resolver': {
     'import/resolver': {
@@ -40,7 +30,6 @@ module.exports = {
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     '@typescript-eslint/no-use-before-define': ['warn'],
     '@typescript-eslint/no-use-before-define': ['warn'],
     '@typescript-eslint/no-this-alias': ['warn'],
     '@typescript-eslint/no-this-alias': ['warn'],
-    'jest/no-done-callback': ['warn'],
   },
   },
   overrides: [
   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 { URL } = require('url');
 
 
-const { initMongooseGlobalSettings, getMongoUri, mongoOptions } = isProduction
+const { getMongoUri, mongoOptions } = isProduction
   // eslint-disable-next-line import/extensions, import/no-unresolved
   // eslint-disable-next-line import/extensions, import/no-unresolved
   ? require('../dist/server/util/mongoose-utils')
   ? require('../dist/server/util/mongoose-utils')
   : require('../src/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.');
   throw new Error('An env var MIGRATIONS_DIR must be set.');
 }
 }
 
 
-
-initMongooseGlobalSettings();
-
 const mongoUri = getMongoUri();
 const mongoUri = getMongoUri();
 
 
 // parse url
 // 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'],
   moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
 
 
   projects: [
   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',
       displayName: 'server',
 
 

+ 16 - 12
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "6.1.2-RC.0",
+  "version": "6.1.3-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -35,9 +35,11 @@
     "prelint:swagger2openapi": "yarn openapi:v3",
     "prelint:swagger2openapi": "yarn openapi:v3",
     "test": "run-p test:*",
     "test": "run-p test:*",
     "test:jest": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
     "test:jest": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
-    "test:vitest": "cross-env NODE_ENV=test vitest run src",
+    "test:vitest": "run-p vitest:run vitest:run:integ",
     "jest:run": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "jest:run": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
     "reg:run": "reg-suit run",
+    "vitest:run": "vitest run config src --coverage",
+    "vitest:run:integ": "vitest run -c vitest.config.integ.ts src --coverage",
     "//// misc": "",
     "//// misc": "",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
@@ -61,14 +63,14 @@
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/core": "^6.1.2-RC.0",
-    "@growi/hackmd": "^6.1.2-RC.0",
-    "@growi/preset-themes": "^6.1.2-RC.0",
-    "@growi/remark-attachment-refs": "^6.1.2-RC.0",
-    "@growi/remark-drawio": "^6.1.2-RC.0",
-    "@growi/remark-growi-directive": "^6.1.2-RC.0",
-    "@growi/remark-lsx": "^6.1.2-RC.0",
-    "@growi/slack": "^6.1.2-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/express": "^7.0.6",
     "@promster/server": "^7.0.8",
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
@@ -131,6 +133,7 @@
     "mongoose-unique-validator": "^2.0.3",
     "mongoose-unique-validator": "^2.0.3",
     "multer": "~1.4.0",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "multer-autoreap": "^1.0.3",
+    "mustache": "^4.2.0",
     "next": "^13.3.0",
     "next": "^13.3.0",
     "next-i18next": "^13.2.1",
     "next-i18next": "^13.2.1",
     "next-superjson": "^0.0.4",
     "next-superjson": "^0.0.4",
@@ -204,8 +207,8 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@growi/presentation": "^6.1.2-RC.0",
-    "@growi/ui": "^6.1.2-RC.0",
+    "@growi/presentation": "^6.1.3-RC.0",
+    "@growi/ui": "^6.1.3-RC.0",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^13.2.3",
     "@next/bundle-analyzer": "^13.2.3",
@@ -229,6 +232,7 @@
     "jquery.cookie": "~1.4.1",
     "jquery.cookie": "~1.4.1",
     "load-css-file": "^1.0.0",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
     "material-icons": "^1.11.3",
+    "mongodb-memory-server": "^8.12.2",
     "morgan": "^1.10.0",
     "morgan": "^1.10.0",
     "null-loader": "^4.0.1",
     "null-loader": "^4.0.1",
     "penpal": "^4.0.0",
     "penpal": "^4.0.0",

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

@@ -290,7 +290,7 @@
     "management_wiki": "Management Wiki",
     "management_wiki": "Management Wiki",
     "system_information": "System information",
     "system_information": "System information",
     "wiki_administrator": "Only wiki administrator can access this page",
     "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",
     "package_name": "Package name",
     "specified_version": "Specified version",
     "specified_version": "Specified version",
     "installed_version": "Installed version",
     "installed_version": "Installed version",
@@ -743,9 +743,9 @@
       "accept": "Accept",
       "accept": "Accept",
       "deactivate_account": "Deactivate account",
       "deactivate_account": "Deactivate account",
       "your_own": "You cannot deactivate your own 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",
       "revoke_read_only_access": "Revoke read only access",
       "grant_read_only_access": "Grant read only access",
       "grant_read_only_access": "Grant read only access",
       "send_invitation_email": "Send invitation email",
       "send_invitation_email": "Send invitation email",
@@ -1018,8 +1018,8 @@
     "ADMIN_USERS_PASSWORD_RESET": "Reset user password",
     "ADMIN_USERS_PASSWORD_RESET": "Reset user password",
     "ADMIN_USERS_ACTIVATE": "Activate user",
     "ADMIN_USERS_ACTIVATE": "Activate user",
     "ADMIN_USERS_DEACTIVATE": "Deactivate 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_GRANT_READ_ONLY": "Grant read only access",
     "ADMIN_USERS_REVOKE_READ_ONLY": "Revoke read only access",
     "ADMIN_USERS_REVOKE_READ_ONLY": "Revoke read only access",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "Resend invitation email",
     "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"
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
   },
   "toaster": {
   "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",
     "grant_user_read_only": "Succeeded to grant {{username}} read only",
     "revoke_user_read_only": "Succeeded to revoke {{username}} read only",
     "revoke_user_read_only": "Succeeded to revoke {{username}} read only",
     "activate_user_success": "Succeeded to activating {{username}}",
     "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."
     "could_not_creata_path": "Couldn't create path."
   },
   },
   "custom_navigation": {
   "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": {
   "installer": {
     "tab": "Create account",
     "tab": "Create account",
@@ -445,12 +445,6 @@
     "file_upload_succeeded": "File upload succeeded.",
     "file_upload_succeeded": "File upload succeeded.",
     "file_upload_failed": "File upload failed.",
     "file_upload_failed": "File upload failed.",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "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}}",
     "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
     "issue_share_link": "Succeeded to issue new share link",
     "issue_share_link": "Succeeded to issue new share link",
     "remove_share_link": "Succeeded to remove {{count}} share links",
     "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": "承認する",
       "accept": "承認する",
       "deactivate_account": "アカウント停止",
       "deactivate_account": "アカウント停止",
       "your_own": "自分自身のアカウントを停止することはできません",
       "your_own": "自分自身のアカウントを停止することはできません",
-      "remove_admin_access": "管理者から外す",
-      "cannot_remove": "自分自身を管理者から外すことはできません",
-      "give_admin_access": "管理者にする",
+      "revoke_admin_access": "管理者から外す",
+      "cannot_revoke": "自分自身を管理者から外すことはできません",
+      "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": "招待メールの送信",
@@ -1026,8 +1026,8 @@
     "ADMIN_USERS_PASSWORD_RESET": "ユーザーのパスワードをリセット",
     "ADMIN_USERS_PASSWORD_RESET": "ユーザーのパスワードをリセット",
     "ADMIN_USERS_ACTIVATE": "ユーザーを承認する",
     "ADMIN_USERS_ACTIVATE": "ユーザーを承認する",
     "ADMIN_USERS_DEACTIVATE": "ユーザーを停止する",
     "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_GRANT_READ_ONLY": "閲覧のみアクセス権を付与する",
     "ADMIN_USERS_REVOKE_READ_ONLY": "閲覧のみアクセス権を外す",
     "ADMIN_USERS_REVOKE_READ_ONLY": "閲覧のみアクセス権を外す",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "招待メールの再送信",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "招待メールの再送信",
@@ -1046,8 +1046,8 @@
     "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
     "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
   },
   },
   "toaster": {
   "toaster": {
-    "give_user_admin": "{{username}}を管理者に設定しました",
-    "remove_user_admin": "{{username}}を管理者から外しました",
+    "grant_user_admin": "{{username}}を管理者に設定しました",
+    "revoke_user_admin": "{{username}}を管理者から外しました",
     "grant_user_read_only": "{{username}}に閲覧のみアクセス権を付与しました",
     "grant_user_read_only": "{{username}}に閲覧のみアクセス権を付与しました",
     "revoke_user_read_only": "{{username}}から閲覧のみアクセス権を外しました",
     "revoke_user_read_only": "{{username}}から閲覧のみアクセス権を外しました",
     "activate_user_success": "{{username}}を有効化しました",
     "activate_user_success": "{{username}}を有効化しました",

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

@@ -169,7 +169,7 @@
     "could_not_creata_path": "パスを作成できませんでした。"
     "could_not_creata_path": "パスを作成できませんでした。"
   },
   },
   "custom_navigation": {
   "custom_navigation": {
-    "no_page_list": "このページの配下にはページが存在しません。"
+    "no_pages_under_this_page": "このページの配下にはページが存在しません。"
   },
   },
   "installer": {
   "installer": {
     "tab": "アカウント作成",
     "tab": "アカウント作成",
@@ -478,12 +478,6 @@
     "file_upload_succeeded": "ファイルをアップロードしました",
     "file_upload_succeeded": "ファイルをアップロードしました",
     "file_upload_failed": "ファイルのアップロードに失敗しました",
     "file_upload_failed": "ファイルのアップロードに失敗しました",
     "initialize_successed": "{{target}}を初期化しました",
     "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}}を削除しました",
     "remove_share_link_success": "{{shareLinkId}}を削除しました",
     "issue_share_link": "共有リンクを作成しました",
     "issue_share_link": "共有リンクを作成しました",
     "remove_share_link": "共有リンクを{{count}}件削除しました",
     "remove_share_link": "共有リンクを{{count}}件削除しました",

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

@@ -751,9 +751,9 @@
       "accept": "接受",
       "accept": "接受",
       "deactivate_account": "停用帐户",
       "deactivate_account": "停用帐户",
       "your_own": "您不能停用自己的帐户",
       "your_own": "您不能停用自己的帐户",
-      "remove_admin_access": "删除管理员访问权限",
-      "cannot_remove": "您不能从管理员中删除自己",
-      "give_admin_access": "授予管理员访问权限",
+      "revoke_admin_access": "删除管理员访问权限",
+      "cannot_revoke": "您不能从管理员中删除自己",
+      "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": "发送邀请邮件",
@@ -1026,8 +1026,8 @@
     "ADMIN_USERS_PASSWORD_RESET": "重置用户密码",
     "ADMIN_USERS_PASSWORD_RESET": "重置用户密码",
     "ADMIN_USERS_ACTIVATE": "激活用户",
     "ADMIN_USERS_ACTIVATE": "激活用户",
     "ADMIN_USERS_DEACTIVATE": "停用用户",
     "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_GRANT_READ_ONLY": "给予只读权限",
     "ADMIN_USERS_REVOKE_READ_ONLY": "取消只读访问",
     "ADMIN_USERS_REVOKE_READ_ONLY": "取消只读访问",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "重发邀请函",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "重发邀请函",
@@ -1046,8 +1046,8 @@
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
   },
   "toaster": {
   "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",
     "grant_user_read_only": "Succeeded to grant {{username}} read only",
     "revoke_user_read_only": "Succeeded to revoke {{username}} read only",
     "revoke_user_read_only": "Succeeded to revoke {{username}} read only",
 		"activate_user_success": "Succeeded to activating {{username}}",
 		"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": "无法创建路径"
     "could_not_creata_path": "无法创建路径"
   },
   },
   "custom_navigation": {
   "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": {
 	"installer": {
     "tab": "创建账户",
     "tab": "创建账户",
@@ -434,12 +434,6 @@
     "file_upload_succeeded": "文件上传成功",
     "file_upload_succeeded": "文件上传成功",
     "file_upload_failed": "文件上传失败",
     "file_upload_failed": "文件上传失败",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "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": "成功更新分享链接设置",
     "switch_disable_link_sharing_success": "成功更新分享链接设置",
     "failed_to_reset_password":"Failed to reset password",
     "failed_to_reset_password":"Failed to reset password",
     "save_succeeded": "已成功保存",
     "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
    * @memberOf AdminUsersContainer
    * @param {string} userId
    * @param {string} userId
    * @return {string} username
    * @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;
     const { username } = response.data.userData;
     await this.retrieveUsersByPagingNum(this.state.activePage);
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
     return username;
   }
   }
 
 
   /**
   /**
-   * Remove user admin
+   * Revoke user admin
    * @memberOf AdminUsersContainer
    * @memberOf AdminUsersContainer
    * @param {string} userId
    * @param {string} userId
    * @return {string} username
    * @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;
     const { username } = response.data.userData;
     await this.retrieveUsersByPagingNum(this.state.activePage);
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
     return username;

+ 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';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-type GiveAdminButtonProps = {
+type GrantAdminButtonProps = {
   adminUsersContainer: AdminUsersContainer,
   adminUsersContainer: AdminUsersContainer,
   user: IUserHasId,
   user: IUserHasId,
 }
 }
 
 
-const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
+const GrantAdminButton = (props: GrantAdminButtonProps): JSX.Element => {
 
 
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
   const { adminUsersContainer, user } = props;
   const { adminUsersContainer, user } = props;
 
 
-  const onClickGiveAdminBtnHandler = useCallback(async() => {
+  const onClickGrantAdminBtnHandler = useCallback(async() => {
     try {
     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) {
     catch (err) {
       toastError(err);
       toastError(err);
@@ -29,8 +29,8 @@ const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
   }, [adminUsersContainer, t, user._id]);
   }, [adminUsersContainer, t, user._id]);
 
 
   return (
   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>
     </button>
   );
   );
 
 
@@ -40,6 +40,6 @@ const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
 // eslint-disable-next-line max-len
 // 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';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-type RemoveAdminButtonProps = {
+type RevokeAdminButtonProps = {
   adminUsersContainer: AdminUsersContainer,
   adminUsersContainer: AdminUsersContainer,
   user: IUserHasId,
   user: IUserHasId,
 }
 }
 
 
-const RemoveAdminButton = (props: RemoveAdminButtonProps): JSX.Element => {
+const RevokeAdminButton = (props: RevokeAdminButtonProps): JSX.Element => {
 
 
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
   const { adminUsersContainer, user } = props;
   const { adminUsersContainer, user } = props;
 
 
-  const onClickRemoveAdminBtnHandler = useCallback(async() => {
+  const onClickRevokeAdminBtnHandler = useCallback(async() => {
     try {
     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) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [adminUsersContainer, t, user._id]);
   }, [adminUsersContainer, t, user._id]);
 
 
-  const renderRemoveAdminBtn = () => {
+  const renderRevokeAdminBtn = () => {
     return (
     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>
       </button>
     );
     );
   };
   };
 
 
-  const renderRemoveAdminAlert = () => {
+  const renderRevokeAdminAlert = () => {
     return (
     return (
       <div className="px-4">
       <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>
       </div>
     );
     );
   };
   };
@@ -53,8 +53,8 @@ const RemoveAdminButton = (props: RemoveAdminButtonProps): JSX.Element => {
 
 
   return (
   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
 * 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';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 
 
-const RemoveAdminAlert = React.memo((): JSX.Element => {
+const RevokeAdminAlert = React.memo((): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   return (
   return (
     <div className="px-4">
     <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>
     </div>
   );
   );
 });
 });
-RemoveAdminAlert.displayName = 'RemoveAdminAlert';
+RevokeAdminAlert.displayName = 'RevokeAdminAlert';
 
 
 
 
 type Props = {
 type Props = {
@@ -28,17 +28,17 @@ type Props = {
   user: IUserHasId,
   user: IUserHasId,
 }
 }
 
 
-const RemoveAdminMenuItem = (props: Props): JSX.Element => {
+const RevokeAdminMenuItem = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const { adminUsersContainer, user } = props;
   const { adminUsersContainer, user } = props;
 
 
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
 
 
-  const clickRemoveAdminBtnHandler = useCallback(async() => {
+  const clickRevokeAdminBtnHandler = useCallback(async() => {
     try {
     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) {
     catch (err) {
       toastError(err);
       toastError(err);
@@ -48,17 +48,17 @@ const RemoveAdminMenuItem = (props: Props): JSX.Element => {
 
 
   return user.username !== currentUser?.username
   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>
       </button>
     )
     )
-    : <RemoveAdminAlert />;
+    : <RevokeAdminAlert />;
 };
 };
 
 
 /**
 /**
 * Wrapper component for using unstated
 * Wrapper component for using unstated
 */
 */
 // eslint-disable-next-line max-len
 // 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 { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-import GiveAdminButton from './GiveAdminButton';
+import GrantAdminButton from './GrantAdminButton';
 import GrantReadOnlyButton from './GrantReadOnlyButton';
 import GrantReadOnlyButton from './GrantReadOnlyButton';
-import RemoveAdminMenuItem from './RemoveAdminMenuItem';
+import RevokeAdminMenuItem from './RevokeAdminMenuItem';
 import RevokeReadOnlyMenuItem from './RevokeReadOnlyMenuItem';
 import RevokeReadOnlyMenuItem from './RevokeReadOnlyMenuItem';
 import SendInvitationEmailButton from './SendInvitationEmailButton';
 import SendInvitationEmailButton from './SendInvitationEmailButton';
 import StatusActivateButton from './StatusActivateButton';
 import StatusActivateButton from './StatusActivateButton';
@@ -83,7 +83,7 @@ const UserMenu = (props: UserMenuProps) => {
         <li className="dropdown-divider pl-0"></li>
         <li className="dropdown-divider pl-0"></li>
         <li className="dropdown-header">{t('user_management.user_table.administrator_menu')}</li>
         <li className="dropdown-header">{t('user_management.user_table.administrator_menu')}</li>
         <li>
         <li>
-          {user.admin ? <RemoveAdminMenuItem user={user} /> : <GiveAdminButton user={user} />}
+          {user.admin ? <RevokeAdminMenuItem user={user} /> : <GrantAdminButton user={user} />}
         </li>
         </li>
         <li>
         <li>
           {user.readOnly ? <RevokeReadOnlyMenuItem user={user} /> : <GrantReadOnlyButton user={user} />}
           {user.readOnly ? <RevokeReadOnlyMenuItem user={user} /> : <GrantReadOnlyButton user={user} />}

+ 4 - 3
apps/app/src/components/InstallerForm.tsx

@@ -214,7 +214,7 @@ const InstallerForm = memo((): JSX.Element => {
             />
             />
           </div>
           </div>
 
 
-          <div className="input-group mt-4 mb-3 d-flex justify-content-center">
+          <div className="input-group mt-4 d-flex justify-content-center">
             <button
             <button
               data-testid="btnSubmit"
               data-testid="btnSubmit"
               type="submit"
               type="submit"
@@ -228,11 +228,12 @@ const InstallerForm = memo((): JSX.Element => {
             </button>
             </button>
           </div>
           </div>
 
 
-          <div className="input-group mt-4 d-flex justify-content-center">
+          <div>
             <a href="https://growi.org" className="link-growi-org">
             <a href="https://growi.org" className="link-growi-org">
-              <span className="growi">GROWI</span>.<span className="org">ORG</span>
+              <span className="growi">GROWI</span>.<span className="org">org</span>
             </a>
             </a>
           </div>
           </div>
+
         </form>
         </form>
       </div>
       </div>
     </div>
     </div>

+ 0 - 6
apps/app/src/components/Layout/NoLoginLayout.module.scss

@@ -23,12 +23,6 @@
       }
       }
     }
     }
 
 
-    .link-growi-org {
-      position: absolute;
-      bottom: 9px;
-      z-index: 3;
-    }
-
   }
   }
 
 
   // styles
   // styles

+ 6 - 0
apps/app/src/components/LoginForm.module.scss

@@ -9,4 +9,10 @@
   .collapse-external-auth {
   .collapse-external-auth {
     overflow: hidden;
     overflow: hidden;
   }
   }
+
+  .link-growi-org {
+    position: absolute;
+    bottom: 9px;
+    z-index: 3;
+  }
 }
 }

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

@@ -543,7 +543,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           </div>
           </div>
         </div>
         </div>
         <a href="https://growi.org" className="link-growi-org pl-3">
         <a href="https://growi.org" className="link-growi-org pl-3">
-          <span className="growi">GROWI</span>.<span className="org">ORG</span>
+          <span className="growi">GROWI</span>.<span className="org">org</span>
         </a>
         </a>
       </div>
       </div>
     </div>
     </div>

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

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

+ 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 { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import Link from 'next/link';
 
 
-import { apiv3Get } from '~/client/util/apiv3-client';
 import { IPageHasId } from '~/interfaces/page';
 import { IPageHasId } from '~/interfaces/page';
 import { useCurrentPagePath } from '~/stores/page';
 import { useCurrentPagePath } from '~/stores/page';
+import { useSWRINFxPageTimeline } from '~/stores/page-timeline';
 import { useTimelineOptions } from '~/stores/renderer';
 import { useTimelineOptions } from '~/stores/renderer';
 
 
+import InfiniteScroll from './InfiniteScroll';
 import { RevisionLoader } from './Page/RevisionLoader';
 import { RevisionLoader } from './Page/RevisionLoader';
-import PaginationWrapper from './PaginationWrapper';
 
 
 import styles from './PageTimeline.module.scss';
 import styles from './PageTimeline.module.scss';
 
 
@@ -42,48 +42,38 @@ const TimelineCard = ({ page }: TimelineCardProps): JSX.Element => {
   );
   );
 };
 };
 
 
-
 export const PageTimeline = (): 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 { 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 (
     return (
       <div className="mt-2">
       <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>
       </div>
     );
     );
   }
   }
 
 
   return (
   return (
     <div>
     <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>
     </div>
   );
   );
 };
 };

+ 27 - 8
apps/app/src/components/TemplateModal.tsx → apps/app/src/components/TemplateModal/index.tsx

@@ -1,6 +1,8 @@
-import React, { useCallback, useState } from 'react';
+import React, {
+  useCallback, useEffect, useState,
+} from 'react';
 
 
-import { ITemplate } from '@growi/core';
+import type { ITemplate } from '@growi/core/dist/interfaces/template';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   Modal,
   Modal,
@@ -12,8 +14,13 @@ import {
 import { useTemplateModal } from '~/stores/modal';
 import { useTemplateModal } from '~/stores/modal';
 import { usePreviewOptions } from '~/stores/renderer';
 import { usePreviewOptions } from '~/stores/renderer';
 import { useTemplates } from '~/stores/template';
 import { useTemplates } from '~/stores/template';
+import loggerFactory from '~/utils/logger';
 
 
-import Preview from './PageEditor/Preview';
+import Preview from '../PageEditor/Preview';
+
+import { useFormatter } from './use-formatter';
+
+const logger = loggerFactory('growi:components:TemplateModal');
 
 
 
 
 type TemplateRadioButtonProps = {
 type TemplateRadioButtonProps = {
@@ -44,6 +51,7 @@ const TemplateRadioButton = ({ template, onChange, isSelected }: TemplateRadioBu
 export const TemplateModal = (): JSX.Element => {
 export const TemplateModal = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
+
   const { data: templateModalStatus, close } = useTemplateModal();
   const { data: templateModalStatus, close } = useTemplateModal();
 
 
   const { data: rendererOptions } = usePreviewOptions();
   const { data: rendererOptions } = usePreviewOptions();
@@ -51,16 +59,27 @@ export const TemplateModal = (): JSX.Element => {
 
 
   const [selectedTemplate, setSelectedTemplate] = useState<ITemplate>();
   const [selectedTemplate, setSelectedTemplate] = useState<ITemplate>();
 
 
+  const { format } = useFormatter();
+
   const submitHandler = useCallback((template?: ITemplate) => {
   const submitHandler = useCallback((template?: ITemplate) => {
-    if (templateModalStatus == null) { return }
+    if (templateModalStatus == null || selectedTemplate == null) {
+      return;
+    }
+
     if (templateModalStatus.onSubmit == null || template == null) {
     if (templateModalStatus.onSubmit == null || template == null) {
       close();
       close();
       return;
       return;
     }
     }
 
 
-    templateModalStatus.onSubmit(template.markdown);
+    templateModalStatus.onSubmit(format(selectedTemplate));
     close();
     close();
-  }, [close, templateModalStatus]);
+  }, [close, format, selectedTemplate, templateModalStatus]);
+
+  useEffect(() => {
+    if (!templateModalStatus?.isOpened) {
+      setSelectedTemplate(undefined);
+    }
+  }, [templateModalStatus?.isOpened]);
 
 
   if (templates == null || templateModalStatus == null) {
   if (templates == null || templateModalStatus == null) {
     return <></>;
     return <></>;
@@ -86,13 +105,13 @@ export const TemplateModal = (): JSX.Element => {
           </div>
           </div>
         </div>
         </div>
 
 
-        { rendererOptions != null && (
+        { rendererOptions != null && selectedTemplate != null && (
           <>
           <>
             <hr />
             <hr />
             <h3>Preview</h3>
             <h3>Preview</h3>
             <div className='card'>
             <div className='card'>
               <div className="card-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
               <div className="card-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
-                <Preview rendererOptions={rendererOptions} markdown={selectedTemplate?.markdown}/>
+                <Preview rendererOptions={rendererOptions} markdown={format(selectedTemplate)}/>
               </div>
               </div>
             </div>
             </div>
           </>
           </>

+ 101 - 0
apps/app/src/components/TemplateModal/use-formatter.spec.tsx

@@ -0,0 +1,101 @@
+import type { ITemplate } from '@growi/core/dist/interfaces/template';
+import { mock } from 'vitest-mock-extended';
+
+import { useFormatter } from './use-formatter';
+
+
+const mocks = vi.hoisted(() => {
+  return {
+    useCurrentPagePathMock: vi.fn(() => { return {} }),
+  };
+});
+
+vi.mock('~/stores/page', () => {
+  return { useCurrentPagePath: mocks.useCurrentPagePathMock };
+});
+
+
+describe('useFormatter', () => {
+
+  describe('format()', () => {
+
+    it('returns an empty string when the argument is undefined', () => {
+      // setup
+      const mastacheMock = {
+        render: vi.fn(),
+      };
+      vi.doMock('mustache', () => mastacheMock);
+
+      // when
+      const { format } = useFormatter();
+      // call with undefined
+      const markdown = format(undefined);
+
+      // then
+      expect(markdown).toBe('');
+      expect(mastacheMock.render).not.toHaveBeenCalled();
+    });
+
+  });
+
+  it('returns markdown as-is when mustache.render throws an error', () => {
+    // setup
+    const mastacheMock = {
+      render: vi.fn(() => { throw new Error() }),
+    };
+    vi.doMock('mustache', () => mastacheMock);
+
+    // when
+    const { format } = useFormatter();
+    const template = mock<ITemplate>();
+    template.markdown = 'markdown body';
+    const markdown = format(template);
+
+    // then
+    expect(markdown).toBe('markdown body');
+  });
+
+  it('returns markdown formatted when currentPagePath is undefined', () => {
+    // when
+    const { format } = useFormatter();
+    const template = mock<ITemplate>();
+    template.markdown = `
+title: {{{title}}}{{^title}}(empty){{/title}}
+path: {{{path}}}
+`;
+    const markdown = format(template);
+
+    // then
+    expect(markdown).toBe(`
+title: (empty)
+path: /
+`);
+  });
+
+  it('returns markdown formatted', () => {
+    // setup
+    mocks.useCurrentPagePathMock.mockImplementation(() => {
+      return { data: '/Sandbox' };
+    });
+    // 2023/5/31 15:01:xx
+    vi.setSystemTime(new Date(2023, 4, 31, 15, 1));
+
+    // when
+    const { format } = useFormatter();
+    const template = mock<ITemplate>();
+    template.markdown = `
+title: {{{title}}}
+path: {{{path}}}
+date: {{yyyy}}/{{MM}}/{{dd}} {{HH}}:{{mm}}
+`;
+    const markdown = format(template);
+
+    // then
+    expect(markdown).toBe(`
+title: Sandbox
+path: /Sandbox
+date: 2023/05/31 15:01
+`);
+  });
+
+});

+ 48 - 0
apps/app/src/components/TemplateModal/use-formatter.tsx

@@ -0,0 +1,48 @@
+import path from 'path';
+
+import type { ITemplate } from '@growi/core/dist/interfaces/template';
+import dateFnsFormat from 'date-fns/format';
+import mustache from 'mustache';
+
+import { useCurrentPagePath } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:components:TemplateModal:use-formatter');
+
+
+type FormatMethod = (selectedTemplate?: ITemplate) => string;
+type FormatterData = {
+  format: FormatMethod,
+}
+
+export const useFormatter = (): FormatterData => {
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  const format: FormatMethod = (selectedTemplate) => {
+    if (selectedTemplate == null) {
+      return '';
+    }
+
+    // replace placeholder
+    let markdown = selectedTemplate.markdown;
+    const now = new Date();
+    try {
+      markdown = mustache.render(selectedTemplate.markdown, {
+        title: path.basename(currentPagePath ?? '/'),
+        path: currentPagePath ?? '/',
+        yyyy: dateFnsFormat(now, 'yyyy'),
+        MM: dateFnsFormat(now, 'MM'),
+        dd: dateFnsFormat(now, 'dd'),
+        HH: dateFnsFormat(now, 'HH'),
+        mm: dateFnsFormat(now, 'mm'),
+      });
+    }
+    catch (err) {
+      logger.warn('An error occured while ejs processing.', err);
+    }
+
+    return markdown;
+  };
+
+  return { format };
+};

+ 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_INVITE = 'ADMIN_USERS_INVITE';
 const ACTION_ADMIN_USERS_PASSWORD_RESET = 'ADMIN_USERS_PASSWORD_RESET';
 const ACTION_ADMIN_USERS_PASSWORD_RESET = 'ADMIN_USERS_PASSWORD_RESET';
 const ACTION_ADMIN_USERS_ACTIVATE = 'ADMIN_USERS_ACTIVATE';
 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_GRANT_READ_ONLY = 'ADMIN_USERS_GRANT_READ_ONLY';
 const ACTION_ADMIN_USERS_REVOKE_READ_ONLY = 'ADMIN_USERS_REVOKE_READ_ONLY';
 const ACTION_ADMIN_USERS_REVOKE_READ_ONLY = 'ADMIN_USERS_REVOKE_READ_ONLY';
 const ACTION_ADMIN_USERS_DEACTIVATE = 'ADMIN_USERS_DEACTIVATE';
 const ACTION_ADMIN_USERS_DEACTIVATE = 'ADMIN_USERS_DEACTIVATE';
@@ -329,8 +329,8 @@ export const SupportedAction = {
   ACTION_ADMIN_USERS_PASSWORD_RESET,
   ACTION_ADMIN_USERS_PASSWORD_RESET,
   ACTION_ADMIN_USERS_ACTIVATE,
   ACTION_ADMIN_USERS_ACTIVATE,
   ACTION_ADMIN_USERS_DEACTIVATE,
   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_GRANT_READ_ONLY,
   ACTION_ADMIN_USERS_REVOKE_READ_ONLY,
   ACTION_ADMIN_USERS_REVOKE_READ_ONLY,
   ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL,
   ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL,
@@ -516,8 +516,8 @@ export const LargeActionGroup = {
   ACTION_ADMIN_USERS_PASSWORD_RESET,
   ACTION_ADMIN_USERS_PASSWORD_RESET,
   ACTION_ADMIN_USERS_ACTIVATE,
   ACTION_ADMIN_USERS_ACTIVATE,
   ACTION_ADMIN_USERS_DEACTIVATE,
   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_GRANT_READ_ONLY,
   ACTION_ADMIN_USERS_REVOKE_READ_ONLY,
   ACTION_ADMIN_USERS_REVOKE_READ_ONLY,
   ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL,
   ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL,

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

@@ -4,7 +4,7 @@ const repl = require('repl');
 
 
 const mongoose = require('mongoose');
 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');
 const models = require('./models');
 
 
@@ -34,8 +34,6 @@ fs.readFile(replHistoryPath, 'utf8', (err, data) => {
 replServer.context.mongoose = mongoose;
 replServer.context.mongoose = mongoose;
 replServer.context.models = models;
 replServer.context.models = models;
 
 
-initMongooseGlobalSettings();
-
 mongoose.connect(getMongoUri(), mongoOptions)
 mongoose.connect(getMongoUri(), mongoOptions)
   .then(() => {
   .then(() => {
     replServer.context.db = mongoose.connection.db;
     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 loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
 
+import registerSafeRedirectFactory from '../middlewares/safe-redirect';
+
 const logger = loggerFactory('growi:crowi:express-init');
 const logger = loggerFactory('growi:crowi:express-init');
 
 
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
@@ -22,7 +24,7 @@ module.exports = function(crowi, app) {
   const mongoSanitize = require('express-mongo-sanitize');
   const mongoSanitize = require('express-mongo-sanitize');
 
 
   const promster = require('../middlewares/promster')(crowi, app);
   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 injectCurrentuserToLocalvars = require('../middlewares/inject-currentuser-to-localvars')();
   const autoReconnectToS2sMsgServer = require('../middlewares/auto-reconnect-to-s2s-msg-server')(crowi);
   const autoReconnectToS2sMsgServer = require('../middlewares/auto-reconnect-to-s2s-msg-server')(crowi);
 
 

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

@@ -23,10 +23,10 @@ import GrowiPlugin from '../models/growi-plugin';
 import PageRedirect from '../models/page-redirect';
 import PageRedirect from '../models/page-redirect';
 import Tag from '../models/tag';
 import Tag from '../models/tag';
 import UserGroup from '../models/user-group';
 import UserGroup from '../models/user-group';
-import AclService from '../service/acl';
+import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import AppService from '../service/app';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
 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 { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { InstallerService } from '../service/installer';
 import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageService from '../service/page';
@@ -37,7 +37,7 @@ import { PluginService } from '../service/plugin';
 import SearchService from '../service/search';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
 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');
 const logger = loggerFactory('growi:crowi');
@@ -224,8 +224,6 @@ Crowi.prototype.setupDatabase = function() {
   // mongoUri = mongodb://user:password@host/dbname
   // mongoUri = mongodb://user:password@host/dbname
   const mongoUri = getMongoUri();
   const mongoUri = getMongoUri();
 
 
-  initMongooseGlobalSettings();
-
   return mongoose.connect(mongoUri, mongoOptions);
   return mongoose.connect(mongoUri, mongoOptions);
 };
 };
 
 
@@ -276,7 +274,7 @@ Crowi.prototype.setupSessionConfig = async function() {
 };
 };
 
 
 Crowi.prototype.setupConfigManager = async function() {
 Crowi.prototype.setupConfigManager = async function() {
-  this.configManager = new ConfigManager();
+  this.configManager = configManagerSingletonInstance;
   return this.configManager.loadConfigs();
   return this.configManager.loadConfigs();
 };
 };
 
 
@@ -614,9 +612,7 @@ Crowi.prototype.setUpXss = async function() {
  * setup AclService
  * setup AclService
  */
  */
 Crowi.prototype.setUpAcl = async function() {
 Crowi.prototype.setUpAcl = async function() {
-  if (this.aclService == null) {
-    this.aclService = new AclService(this.configManager);
-  }
+  this.aclService = aclServiceSingletonInstance;
 };
 };
 
 
 /**
 /**

+ 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 { ErrorV3 } from '@growi/core';
 
 
-import { excludeReadOnlyUser } from '../../../src/server/middlewares/exclude-read-only-user';
+import { excludeReadOnlyUser } from './exclude-read-only-user';
 
 
 describe('excludeReadOnlyUser', () => {
 describe('excludeReadOnlyUser', () => {
   let req;
   let req;
@@ -12,9 +12,9 @@ describe('excludeReadOnlyUser', () => {
       user: {},
       user: {},
     };
     };
     res = {
     res = {
-      apiv3Err: jest.fn(),
+      apiv3Err: vi.fn(),
     };
     };
-    next = jest.fn();
+    next = vi.fn();
   });
   });
 
 
   test('should call next if user is not found', () => {
   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 = [
   const whitelistOfHosts = [
     'white1.example.com:8080',
     'white1.example.com:8080',
     'white2.example.com',
     'white2.example.com',
   ];
   ];
-
-  beforeEach(async() => {
-    registerSafeRedirect = require('~/server/middlewares/safe-redirect')(whitelistOfHosts);
-  });
+  const registerSafeRedirect = registerSafeRedirectFactory(whitelistOfHosts);
 
 
   describe('res.safeRedirect', () => {
   describe('res.safeRedirect', () => {
     // setup req/res/next
     // setup req/res/next
+    const getFunc = vi.fn().mockReturnValue('example.com');
     const req = {
     const req = {
       protocol: 'http',
       protocol: 'http',
       hostname: 'example.com',
       hostname: 'example.com',
-      get: jest.fn().mockReturnValue('example.com'),
-    };
+      get: getFunc,
+    } as any as Request;
+
+    const redirect = vi.fn();
     const res = {
     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', () => {
     test('redirects to \'/\' because specified url causes open redirect vulnerability', () => {
       registerSafeRedirect(req, res, next);
       registerSafeRedirect(req, res, next);
 
 
-      const result = res.safeRedirect('//evil.example.com');
+      res.safeRedirect('//evil.example.com');
 
 
       expect(next).toHaveBeenCalledTimes(1);
       expect(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('/');
       expect(res.redirect).toHaveBeenCalledWith('/');
-      expect(result).toBe('redirect');
     });
     });
 
 
     test('redirects to \'/\' because specified host without port is not in whitelist', () => {
     test('redirects to \'/\' because specified host without port is not in whitelist', () => {
       registerSafeRedirect(req, res, next);
       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(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('/');
       expect(res.redirect).toHaveBeenCalledWith('/');
-      expect(result).toBe('redirect');
     });
     });
 
 
     test('redirects to the specified local url', () => {
     test('redirects to the specified local url', () => {
       registerSafeRedirect(req, res, next);
       registerSafeRedirect(req, res, next);
 
 
-      const result = res.safeRedirect('/path/to/page');
+      res.safeRedirect('/path/to/page');
 
 
       expect(next).toHaveBeenCalledTimes(1);
       expect(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('http://example.com/path/to/page');
       expect(res.redirect).toHaveBeenCalledWith('http://example.com/path/to/page');
-      expect(result).toBe('redirect');
     });
     });
 
 
     test('redirects to the specified local url (fqdn)', () => {
     test('redirects to the specified local url (fqdn)', () => {
       registerSafeRedirect(req, res, next);
       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(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('http://example.com/path/to/page');
       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)', () => {
     test('redirects to the specified whitelisted url (white1.example.com:8080)', () => {
       registerSafeRedirect(req, res, next);
       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(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('http://white1.example.com:8080/path/to/page');
       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)', () => {
     test('redirects to the specified whitelisted url (white2.example.com:8080)', () => {
       registerSafeRedirect(req, res, next);
       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(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('http://white2.example.com:8080/path/to/page');
       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']))
  * Usage: app.use(require('middlewares/safe-redirect')(['example.com', 'some.example.com:8080']))
  */
  */
 
 
-import {
+import type {
   Request, Response, NextFunction,
   Request, Response, NextFunction,
 } from 'express';
 } from 'express';
 
 
@@ -31,13 +31,13 @@ function isInWhitelist(whitelistOfHosts: string[], redirectToFqdn: string): bool
 }
 }
 
 
 
 
-type ResWithSafeRedirect = Response & {
+export type ResWithSafeRedirect = Response & {
   safeRedirect: (redirectTo?: string) => void,
   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
     // extend res object
     res.safeRedirect = function(redirectTo?: string) {
     res.safeRedirect = function(redirectTo?: string) {
@@ -75,3 +75,5 @@ module.exports = (whitelistOfHosts: string[]) => {
   };
   };
 
 
 };
 };
+
+export default factory;

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

@@ -1,6 +1,7 @@
-import { RuleTester } from 'eslint';
 import { test } from 'vitest';
 import { test } from 'vitest';
 
 
+import { RuleTester } from 'eslint';
+
 import noPopulate from '../no-populate';
 import noPopulate from '../no-populate';
 
 
 const ruleTester = new RuleTester({
 const ruleTester = new RuleTester({

+ 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();
     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();
     return this.save();
   };
   };
 
 
   userSchema.methods.grantReadOnly = async function() {
   userSchema.methods.grantReadOnly = async function() {
-    logger.debug('Grant read only flag', this);
+    logger.debug('Grant read only access', this);
     this.readOnly = 1;
     this.readOnly = 1;
     return this.save();
     return this.save();
   };
   };
 
 
   userSchema.methods.revokeReadOnly = async function() {
   userSchema.methods.revokeReadOnly = async function() {
-    logger.debug('Revoke read only flag', this);
+    logger.debug('Revoke read only access', this);
     this.readOnly = 0;
     this.readOnly = 0;
     return this.save();
     return this.save();
   };
   };
 
 
-  userSchema.methods.asyncMakeAdmin = async function(callback) {
+  userSchema.methods.asyncGrantAdmin = async function(callback) {
     this.admin = 1;
     this.admin = 1;
     return this.save();
     return this.save();
   };
   };

+ 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 logger = loggerFactory('growi:routes:apiv3:users');
 
 
+const path = require('path');
+
 const express = require('express');
 const express = require('express');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
-const path = require('path');
-
 const { body, query } = require('express-validator');
 const { body, query } = require('express-validator');
 const { isEmail } = require('validator');
 const { isEmail } = require('validator');
 
 
@@ -453,12 +453,12 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *  paths:
    *  paths:
-   *    /users/{id}/giveAdmin:
+   *    /users/{id}/grant-admin:
    *      put:
    *      put:
    *        tags: [Users]
    *        tags: [Users]
-   *        operationId: giveAdminUser
-   *        summary: /users/{id}/giveAdmin
-   *        description: Give user admin
+   *        operationId: grantAdminUser
+   *        summary: /users/{id}/grant-admin
+   *        description: Grant user admin
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
    *            in: path
    *            in: path
@@ -468,7 +468,7 @@ module.exports = (crowi) => {
    *              type: string
    *              type: string
    *        responses:
    *        responses:
    *          200:
    *          200:
-   *            description: Give user admin success
+   *            description: Grant user admin success
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
@@ -477,16 +477,16 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: data of admin user
    *                      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;
     const { id } = req.params;
 
 
     try {
     try {
       const userData = await User.findById(id);
       const userData = await User.findById(id);
-      await userData.makeAdmin();
+      await userData.grantAdmin();
 
 
       const serializedUserData = serializeUserSecurely(userData);
       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 });
       return res.apiv3({ userData: serializedUserData });
     }
     }
@@ -500,40 +500,40 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *  paths:
    *  paths:
-   *    /users/{id}/removeAdmin:
+   *    /users/{id}/revoke-admin:
    *      put:
    *      put:
    *        tags: [Users]
    *        tags: [Users]
-   *        operationId: removeAdminUser
-   *        summary: /users/{id}/removeAdmin
-   *        description: Remove user admin
+   *        operationId: revokeAdminUser
+   *        summary: /users/{id}/revoke-admin
+   *        description: Revoke user admin
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
    *            in: path
    *            in: path
    *            required: true
    *            required: true
-   *            description: id of user for removing admin
+   *            description: id of user for revoking admin
    *            schema:
    *            schema:
    *              type: string
    *              type: string
    *        responses:
    *        responses:
    *          200:
    *          200:
-   *            description: Remove user admin success
+   *            description: Revoke user admin success
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
    *                  properties:
    *                  properties:
    *                    userData:
    *                    userData:
    *                      type: object
    *                      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;
     const { id } = req.params;
 
 
     try {
     try {
       const userData = await User.findById(id);
       const userData = await User.findById(id);
-      await userData.removeFromAdmin();
+      await userData.revokeAdmin();
 
 
       const serializedUserData = serializeUserSecurely(userData);
       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 });
       return res.apiv3({ userData: serializedUserData });
     }
     }

+ 51 - 38
apps/app/test/integration/service/acl.test.js → apps/app/src/server/service/acl.integ.ts

@@ -1,15 +1,28 @@
-const { getInstance } = require('../setup-crowi');
+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', () => {
 describe('AclService test', () => {
-  let crowi;
 
 
   const initialEnv = process.env;
   const initialEnv = process.env;
 
 
-  beforeEach(async() => {
-    crowi = await getInstance();
-    process.env = initialEnv;
+  beforeAll(async() => {
+    await configManager.loadConfigs();
   });
   });
 
 
+  afterEach(() => {
+    process.env = initialEnv;
+  });
 
 
   describe('isAclEnabled()', () => {
   describe('isAclEnabled()', () => {
 
 
@@ -17,11 +30,11 @@ describe('AclService test', () => {
       delete process.env.FORCE_WIKI_MODE;
       delete process.env.FORCE_WIKI_MODE;
 
 
       // reload
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
 
-      const result = crowi.aclService.isAclEnabled();
+      const result = aclService.isAclEnabled();
 
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe(undefined);
       expect(wikiMode).toBe(undefined);
       expect(result).toBe(true);
       expect(result).toBe(true);
     });
     });
@@ -30,11 +43,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'dummy string';
       process.env.FORCE_WIKI_MODE = 'dummy string';
 
 
       // reload
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
 
-      const result = crowi.aclService.isAclEnabled();
+      const result = aclService.isAclEnabled();
 
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('dummy string');
       expect(wikiMode).toBe('dummy string');
       expect(result).toBe(true);
       expect(result).toBe(true);
     });
     });
@@ -43,11 +56,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'private';
       process.env.FORCE_WIKI_MODE = 'private';
 
 
       // reload
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
 
-      const result = crowi.aclService.isAclEnabled();
+      const result = aclService.isAclEnabled();
 
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('private');
       expect(wikiMode).toBe('private');
       expect(result).toBe(true);
       expect(result).toBe(true);
     });
     });
@@ -56,11 +69,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'public';
       process.env.FORCE_WIKI_MODE = 'public';
 
 
       // reload
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
 
-      const result = crowi.aclService.isAclEnabled();
+      const result = aclService.isAclEnabled();
 
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('public');
       expect(wikiMode).toBe('public');
       expect(result).toBe(false);
       expect(result).toBe(false);
     });
     });
@@ -74,11 +87,11 @@ describe('AclService test', () => {
       delete process.env.FORCE_WIKI_MODE;
       delete process.env.FORCE_WIKI_MODE;
 
 
       // reload
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
 
-      const result = crowi.aclService.isWikiModeForced();
+      const result = aclService.isWikiModeForced();
 
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe(undefined);
       expect(wikiMode).toBe(undefined);
       expect(result).toBe(false);
       expect(result).toBe(false);
     });
     });
@@ -87,11 +100,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'dummy string';
       process.env.FORCE_WIKI_MODE = 'dummy string';
 
 
       // reload
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
 
-      const result = crowi.aclService.isWikiModeForced();
+      const result = aclService.isWikiModeForced();
 
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('dummy string');
       expect(wikiMode).toBe('dummy string');
       expect(result).toBe(false);
       expect(result).toBe(false);
     });
     });
@@ -100,11 +113,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'private';
       process.env.FORCE_WIKI_MODE = 'private';
 
 
       // reload
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
 
-      const result = crowi.aclService.isWikiModeForced();
+      const result = aclService.isWikiModeForced();
 
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('private');
       expect(wikiMode).toBe('private');
       expect(result).toBe(true);
       expect(result).toBe(true);
     });
     });
@@ -113,11 +126,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'public';
       process.env.FORCE_WIKI_MODE = 'public';
 
 
       // reload
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
 
-      const result = crowi.aclService.isWikiModeForced();
+      const result = aclService.isWikiModeForced();
 
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('public');
       expect(wikiMode).toBe('public');
       expect(result).toBe(true);
       expect(result).toBe(true);
     });
     });
@@ -130,18 +143,18 @@ describe('AclService test', () => {
 
 
     beforeEach(async() => {
     beforeEach(async() => {
       // prepare spy for ConfigManager.getConfig
       // prepare spy for ConfigManager.getConfig
-      getConfigSpy = jest.spyOn(crowi.configManager, 'getConfig');
+      getConfigSpy = vi.spyOn(configManager, 'getConfig');
     });
     });
 
 
     test('to be false when FORCE_WIKI_MODE=private', async() => {
     test('to be false when FORCE_WIKI_MODE=private', async() => {
       process.env.FORCE_WIKI_MODE = 'private';
       process.env.FORCE_WIKI_MODE = 'private';
 
 
       // reload
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
 
-      const result = crowi.aclService.isGuestAllowedToRead();
+      const result = aclService.isGuestAllowedToRead();
 
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('private');
       expect(wikiMode).toBe('private');
       expect(getConfigSpy).not.toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
       expect(getConfigSpy).not.toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
       expect(result).toBe(false);
       expect(result).toBe(false);
@@ -151,11 +164,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'public';
       process.env.FORCE_WIKI_MODE = 'public';
 
 
       // reload
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
 
-      const result = crowi.aclService.isGuestAllowedToRead();
+      const result = aclService.isGuestAllowedToRead();
 
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('public');
       expect(wikiMode).toBe('public');
       expect(getConfigSpy).not.toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
       expect(getConfigSpy).not.toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
       expect(result).toBe(true);
       expect(result).toBe(true);
@@ -174,7 +187,7 @@ describe('AclService test', () => {
       test(`when FORCE_WIKI_MODE is undefined and 'security:restrictGuestMode' is '${restrictGuestMode}`, async() => {
       test(`when FORCE_WIKI_MODE is undefined and 'security:restrictGuestMode' is '${restrictGuestMode}`, async() => {
 
 
         // reload
         // reload
-        await crowi.configManager.loadConfigs();
+        await configManager.loadConfigs();
 
 
         // setup mock implementation
         // setup mock implementation
         getConfigSpy.mockImplementation((ns, key) => {
         getConfigSpy.mockImplementation((ns, key) => {
@@ -187,7 +200,7 @@ describe('AclService test', () => {
           throw new Error('Unexpected behavior.');
           throw new Error('Unexpected behavior.');
         });
         });
 
 
-        const result = crowi.aclService.isGuestAllowedToRead();
+        const result = aclService.isGuestAllowedToRead();
 
 
         expect(getConfigSpy).toHaveBeenCalledTimes(2);
         expect(getConfigSpy).toHaveBeenCalledTimes(2);
         expect(getConfigSpy).toHaveBeenCalledWith('crowi', 'security:wikiMode');
         expect(getConfigSpy).toHaveBeenCalledWith('crowi', 'security:wikiMode');

+ 19 - 27
apps/app/src/server/service/acl.js → apps/app/src/server/service/acl.ts

@@ -1,16 +1,25 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-// eslint-disable-next-line no-unused-vars
+import { configManager } from './config-manager';
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:service:AclService');
 const logger = loggerFactory('growi:service:AclService');
 
 
+export interface AclService {
+  get labels(): { [key: string]: string },
+  isAclEnabled(): boolean,
+  isWikiModeForced(): boolean,
+  isGuestAllowedToRead(): boolean,
+  getGuestModeValue(): string,
+}
+
 /**
 /**
  * the service class of AclService
  * the service class of AclService
  */
  */
-class AclService {
+class AclServiceImpl implements AclService {
 
 
-  constructor(configManager) {
-    this.configManager = configManager;
-    this.labels = {
+  get labels() {
+    return {
       SECURITY_RESTRICT_GUEST_MODE_DENY: 'Deny',
       SECURITY_RESTRICT_GUEST_MODE_DENY: 'Deny',
       SECURITY_RESTRICT_GUEST_MODE_READONLY: 'Readonly',
       SECURITY_RESTRICT_GUEST_MODE_READONLY: 'Readonly',
       SECURITY_REGISTRATION_MODE_OPEN: 'Open',
       SECURITY_REGISTRATION_MODE_OPEN: 'Open',
@@ -23,7 +32,7 @@ class AclService {
    * @returns Whether Access Control is enabled or not
    * @returns Whether Access Control is enabled or not
    */
    */
   isAclEnabled() {
   isAclEnabled() {
-    const wikiMode = this.configManager.getConfig('crowi', 'security:wikiMode');
+    const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
     return wikiMode !== 'public';
     return wikiMode !== 'public';
   }
   }
 
 
@@ -31,7 +40,7 @@ class AclService {
    * @returns Whether wiki mode is set
    * @returns Whether wiki mode is set
    */
    */
   isWikiModeForced() {
   isWikiModeForced() {
-    const wikiMode = this.configManager.getConfig('crowi', 'security:wikiMode');
+    const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
     const isPrivateOrPublic = wikiMode === 'private' || wikiMode === 'public';
     const isPrivateOrPublic = wikiMode === 'private' || wikiMode === 'public';
 
 
     return isPrivateOrPublic;
     return isPrivateOrPublic;
@@ -41,7 +50,7 @@ class AclService {
    * @returns Whether guest users are allowed to read public pages
    * @returns Whether guest users are allowed to read public pages
    */
    */
   isGuestAllowedToRead() {
   isGuestAllowedToRead() {
-    const wikiMode = this.configManager.getConfig('crowi', 'security:wikiMode');
+    const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
 
 
     // return false if private wiki mode
     // return false if private wiki mode
     if (wikiMode === 'private') {
     if (wikiMode === 'private') {
@@ -52,7 +61,7 @@ class AclService {
       return true;
       return true;
     }
     }
 
 
-    const guestMode = this.configManager.getConfig('crowi', 'security:restrictGuestMode');
+    const guestMode = configManager.getConfig('crowi', 'security:restrictGuestMode');
 
 
     // 'Readonly' => returns true (allow access to guests)
     // 'Readonly' => returns true (allow access to guests)
     // 'Deny', null, undefined, '', ... everything else => returns false (requires login)
     // 'Deny', null, undefined, '', ... everything else => returns false (requires login)
@@ -65,23 +74,6 @@ class AclService {
       : this.labels.SECURITY_RESTRICT_GUEST_MODE_DENY;
       : this.labels.SECURITY_RESTRICT_GUEST_MODE_DENY;
   }
   }
 
 
-  getRestrictGuestModeLabels() {
-    const labels = {};
-    labels[this.labels.SECURITY_RESTRICT_GUEST_MODE_DENY] = 'security_settings.guest_mode.deny';
-    labels[this.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY] = 'security_settings.guest_mode.readonly';
-
-    return labels;
-  }
-
-  getRegistrationModeLabels() {
-    const labels = {};
-    labels[this.labels.SECURITY_REGISTRATION_MODE_OPEN] = 'security_settings.registration_mode.open';
-    labels[this.labels.SECURITY_REGISTRATION_MODE_RESTRICTED] = 'security_settings.registration_mode.restricted';
-    labels[this.labels.SECURITY_REGISTRATION_MODE_CLOSED] = 'security_settings.registration_mode.closed';
-
-    return labels;
-  }
-
 }
 }
 
 
-module.exports = AclService;
+export const aclService = new AclServiceImpl();

+ 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', () => {
 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()', () => {
   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' };
       const dummyConfig = { dummyKey: 'dummyValue' };
       await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig);
       await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig);
 
 
+      // then
       expect(ConfigModel.bulkWrite).toHaveBeenCalledTimes(1);
       expect(ConfigModel.bulkWrite).toHaveBeenCalledTimes(1);
       expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
       expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).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' };
       const dummyConfig = { dummyKey: 'dummyValue' };
       await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig, true);
       await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig, true);
 
 
+      // then
       expect(ConfigModel.bulkWrite).toHaveBeenCalledTimes(1);
       expect(ConfigModel.bulkWrite).toHaveBeenCalledTimes(1);
       expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
       expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
       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 S2sMessage from '../models/vo/s2s-message';
 
 
 import ConfigLoader, { ConfigObject } from './config-loader';
 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');
 const logger = loggerFactory('growi:service:ConfigManager');
 
 
@@ -36,7 +36,17 @@ const KEYS_FOR_GCS_USE_ONLY_ENV_OPTION = [
   'gcs:uploadNamespace',
   '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();
   private configLoader: ConfigLoader = new ConfigLoader();
 
 
@@ -48,6 +58,16 @@ export default class ConfigManager implements S2sMessageHandlable {
 
 
   private lastLoadedAt?: Date;
   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
    * load configs from the database and the environment variables
    */
    */
@@ -61,68 +81,10 @@ export default class ConfigManager implements S2sMessageHandlable {
     this.lastLoadedAt = new Date();
     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
    * generate an array of config keys from this.configObject
    */
    */
-  getConfigKeys() {
+  private getConfigKeys() {
     // type: fromDB, fromEnvVars
     // type: fromDB, fromEnvVars
     const types = Object.keys(this.configObject);
     const types = Object.keys(this.configObject);
     let namespaces: string[] = [];
     let namespaces: string[] = [];
@@ -152,16 +114,46 @@ export default class ConfigManager implements S2sMessageHandlable {
     return keys;
     return keys;
   }
   }
 
 
-  reloadConfigKeys() {
+  private reloadConfigKeys() {
     this.configKeys = this.getConfigKeys();
     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
    * get a config specified by namespace & key from configs loaded from the database
    *
    *
    * **Do not use this unless absolutely necessary. Use getConfig instead.**
    * **Do not use this unless absolutely necessary. Use getConfig instead.**
    */
    */
   getConfigFromDB(namespace, key) {
   getConfigFromDB(namespace, key) {
+    this.validateInitialized();
     return this.searchOnlyFromDBConfigs(namespace, key);
     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.**
    * **Do not use this unless absolutely necessary. Use getConfig instead.**
    */
    */
   getConfigFromEnvVars(namespace, key) {
   getConfigFromEnvVars(namespace, key) {
+    this.validateInitialized();
     return this.searchOnlyFromEnvVarConfigs(namespace, key);
     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[] = [];
     const queries: any[] = [];
     for (const key of Object.keys(configs)) {
     for (const key of Object.keys(configs)) {
       queries.push({
       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
    * return whether the specified namespace/key should be retrieved only from env vars
    */
    */
-  shouldSearchedFromEnvVarsOnly(namespace, key) {
+  private shouldSearchedFromEnvVarsOnly(namespace, key) {
     return (namespace === 'crowi' && (
     return (namespace === 'crowi' && (
       // siteUrl
       // siteUrl
       (
       (
@@ -273,7 +266,7 @@ export default class ConfigManager implements S2sMessageHandlable {
    * search a specified config from configs loaded from the database at first
    * search a specified config from configs loaded from the database at first
    * and then from configs loaded from the environment variables
    * 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
     // does not exist neither in db nor in env vars
     if (!this.configExistsInDB(namespace, key) && !this.configExistsInEnvVars(namespace, key)) {
     if (!this.configExistsInDB(namespace, key) && !this.configExistsInEnvVars(namespace, key)) {
       logger.debug(`${namespace}.${key} does not exist neither in db nor in env vars`);
       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
    * search a specified config from configs loaded from the database
    */
    */
-  searchOnlyFromDBConfigs(namespace, key) {
+  private searchOnlyFromDBConfigs(namespace, key) {
     if (!this.configExistsInDB(namespace, key)) {
     if (!this.configExistsInDB(namespace, key)) {
       return undefined;
       return undefined;
     }
     }
@@ -320,7 +313,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
   /**
    * search a specified config from configs loaded from the environment variables
    * search a specified config from configs loaded from the environment variables
    */
    */
-  searchOnlyFromEnvVarConfigs(namespace, key) {
+  private searchOnlyFromEnvVarConfigs(namespace, key) {
     if (!this.configExistsInEnvVars(namespace, key)) {
     if (!this.configExistsInEnvVars(namespace, key)) {
       return undefined;
       return undefined;
     }
     }
@@ -331,7 +324,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
   /**
    * check whether a specified config exists in configs loaded from the database
    * 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) {
     if (this.configObject.fromDB[namespace] === undefined) {
       return false;
       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
    * 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) {
     if (this.configObject.fromEnvVars[namespace] === undefined) {
       return false;
       return false;
     }
     }
@@ -350,10 +343,18 @@ export default class ConfigManager implements S2sMessageHandlable {
     return this.configObject.fromEnvVars[namespace][key] !== undefined;
     return this.configObject.fromEnvVars[namespace][key] !== undefined;
   }
   }
 
 
-  convertInsertValue(value) {
+  private convertInsertValue(value) {
     return JSON.stringify(value === '' ? null : value);
     return JSON.stringify(value === '' ? null : value);
   }
   }
 
 
+  /**
+   * Set S2sMessagingServiceDelegator instance
+   * @param s2sMessagingService
+   */
+  setS2sMessagingService(s2sMessagingService: S2sMessagingService): void {
+    this.s2sMessagingService = s2sMessagingService;
+  }
+
   async publishUpdateMessage() {
   async publishUpdateMessage() {
     const s2sMessage = new S2sMessage('configUpdated', { updatedAt: new Date() });
     const s2sMessage = new S2sMessage('configUpdated', { updatedAt: new Date() });
 
 
@@ -385,18 +386,7 @@ export default class ConfigManager implements S2sMessageHandlable {
     return this.loadConfigs();
     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 S2sMessage from '../models/vo/s2s-message';
 
 
-import ConfigManager from './config-manager';
+import type { ConfigManager } from './config-manager';
 import type { IPluginService } from './plugin';
 import type { IPluginService } from './plugin';
-import { S2sMessageHandlable } from './s2s-messaging/handlable';
+import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 
 
 
 const logger = loggerFactory('growi:service:CustomizeService');
 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 loggerFactory from '~/utils/logger';
 
 
 import S2sMessage from '../models/vo/s2s-message';
 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');
 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 loggerFactory from '~/utils/logger';
 
 
+import { configManager } from '../config-manager';
+
+import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+
 
 
 const logger = loggerFactory('growi:service:fileUploaderAws');
 const logger = loggerFactory('growi:service:fileUploaderAws');
 
 
@@ -37,10 +41,41 @@ type AwsConfig = {
   forcePathStyle?: boolean
   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) => {
 module.exports = (crowi) => {
-  const Uploader = require('./uploader');
-  const { configManager } = crowi;
-  const lib = new Uploader(crowi);
+  const lib = new AwsFileUploader(crowi);
 
 
   const getAwsConfig = (): AwsConfig => {
   const getAwsConfig = (): AwsConfig => {
     return {
     return {
@@ -100,7 +135,7 @@ module.exports = (crowi) => {
     return !configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
     return !configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
   };
   };
 
 
-  lib.respond = async function(res, attachment) {
+  (lib as any).respond = async function(res, attachment) {
     if (!lib.getIsUploadable()) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
       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);
     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()) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
@@ -157,7 +192,7 @@ module.exports = (crowi) => {
     return s3.send(new DeleteObjectsCommand(totalParams));
     return s3.send(new DeleteObjectsCommand(totalParams));
   };
   };
 
 
-  lib.deleteFileByFilePath = async function(filePath) {
+  (lib as any).deleteFileByFilePath = async function(filePath) {
     if (!lib.getIsUploadable()) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
@@ -179,7 +214,7 @@ module.exports = (crowi) => {
     return s3.send(new DeleteObjectCommand(params));
     return s3.send(new DeleteObjectCommand(params));
   };
   };
 
 
-  lib.uploadAttachment = async function(fileStream, attachment) {
+  (lib as any).uploadAttachment = async function(fileStream, attachment) {
     if (!lib.getIsUploadable()) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
@@ -216,7 +251,7 @@ module.exports = (crowi) => {
     return s3.send(new PutObjectCommand(params));
     return s3.send(new PutObjectCommand(params));
   };
   };
 
 
-  lib.findDeliveryFile = async function(attachment) {
+  (lib as any).findDeliveryFile = async function(attachment) {
     if (!lib.getIsReadable()) {
     if (!lib.getIsReadable()) {
       throw new Error('AWS is not configured.');
       throw new Error('AWS is not configured.');
     }
     }
@@ -249,7 +284,7 @@ module.exports = (crowi) => {
     return stream;
     return stream;
   };
   };
 
 
-  lib.checkLimit = async function(uploadFileSize) {
+  (lib as any).checkLimit = async function(uploadFileSize) {
     const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
     const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
     const totalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
     const totalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
@@ -258,7 +293,7 @@ module.exports = (crowi) => {
   /**
   /**
    * List files in storage
    * List files in storage
    */
    */
-  lib.listFiles = async function() {
+  (lib as any).listFiles = async function() {
     if (!lib.getIsReadable()) {
     if (!lib.getIsReadable()) {
       throw new Error('AWS is not configured.');
       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 loggerFactory from '~/utils/logger';
 
 
+import { AbstractFileUploader } from './file-uploader';
+
 const logger = loggerFactory('growi:service:fileUploaderAws');
 const logger = loggerFactory('growi:service:fileUploaderAws');
 
 
 const { Storage } = require('@google-cloud/storage');
 const { Storage } = require('@google-cloud/storage');
@@ -9,9 +11,8 @@ let _instance;
 
 
 
 
 module.exports = function(crowi) {
 module.exports = function(crowi) {
-  const Uploader = require('./uploader');
   const { configManager } = crowi;
   const { configManager } = crowi;
-  const lib = new Uploader(crowi);
+  const lib = new AbstractFileUploader(crowi);
 
 
   function getGcsBucket() {
   function getGcsBucket() {
     return configManager.getConfig('crowi', 'gcs:bucket');
     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 { Readable } from 'stream';
+import util from 'util';
+
+import mongoose from 'mongoose';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { configManager } from '../config-manager';
+
+import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+
 const logger = loggerFactory('growi:service:fileUploaderGridfs');
 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) {
 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 COLLECTION_NAME = 'attachmentFiles';
   const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
   const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
 
 
@@ -33,7 +70,7 @@ module.exports = function(crowi) {
     return true;
     return true;
   };
   };
 
 
-  lib.deleteFile = async function(attachment) {
+  (lib as any).deleteFile = async function(attachment) {
     let filenameValue = attachment.fileName;
     let filenameValue = attachment.fileName;
 
 
     if (attachment.filePath != null) { // backward compatibility for v3.3.x or below
     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 });
     return AttachmentFile.promisifiedUnlink({ _id: attachmentFile._id });
   };
   };
 
 
-  lib.deleteFiles = async function(attachments) {
+  (lib as any).deleteFiles = async function(attachments) {
     const filenameValues = attachments.map((attachment) => {
     const filenameValues = attachments.map((attachment) => {
       return attachment.fileName;
       return attachment.fileName;
     });
     });
@@ -87,13 +124,13 @@ module.exports = function(crowi) {
    * - per-file size limit (specified by MAX_FILE_SIZE)
    * - per-file size limit (specified by MAX_FILE_SIZE)
    * - mongodb(gridfs) size limit (specified by MONGO_GRIDFS_TOTAL_LIMIT)
    * - 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 maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
-    const totalLimit = configManager.getFileUploadTotalLimit();
+    const totalLimit = lib.getFileUploadTotalLimit();
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
     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}`);
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
 
     return AttachmentFile.promisifiedWrite(
     return AttachmentFile.promisifiedWrite(
@@ -125,7 +162,7 @@ module.exports = function(crowi) {
    * @param {Attachment} attachment
    * @param {Attachment} attachment
    * @return {stream.Readable} readable stream
    * @return {stream.Readable} readable stream
    */
    */
-  lib.findDeliveryFile = async function(attachment) {
+  (lib as any).findDeliveryFile = async function(attachment) {
     let filenameValue = attachment.fileName;
     let filenameValue = attachment.fileName;
 
 
     if (attachment.filePath != null) { // backward compatibility for v3.3.x or below
     if (attachment.filePath != null) { // backward compatibility for v3.3.x or below
@@ -145,7 +182,7 @@ module.exports = function(crowi) {
   /**
   /**
    * List files in storage
    * List files in storage
    */
    */
-  lib.listFiles = async function() {
+  (lib as any).listFiles = async function() {
     const attachmentFiles = await AttachmentFile.find();
     const attachmentFiles = await AttachmentFile.find();
     return attachmentFiles.map(({ filename: name, length: size }) => ({
     return attachmentFiles.map(({ filename: name, length: size }) => ({
       name, 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 loggerFactory from '~/utils/logger';
 
 
+import { AbstractFileUploader } from './file-uploader';
+
 const logger = loggerFactory('growi:service:fileUploaderLocal');
 const logger = loggerFactory('growi:service:fileUploaderLocal');
 
 
 const fs = require('fs');
 const fs = require('fs');
@@ -13,9 +15,8 @@ const streamToPromise = require('stream-to-promise');
 const urljoin = require('url-join');
 const urljoin = require('url-join');
 
 
 module.exports = function(crowi) {
 module.exports = function(crowi) {
-  const Uploader = require('./uploader');
   const { configManager } = crowi;
   const { configManager } = crowi;
-  const lib = new Uploader(crowi);
+  const lib = new AbstractFileUploader(crowi);
   const basePath = path.posix.join(crowi.publicDir, 'uploads');
   const basePath = path.posix.join(crowi.publicDir, 'uploads');
 
 
   function getFilePathOnStorage(attachment) {
   function getFilePathOnStorage(attachment) {

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

@@ -1,9 +1,10 @@
 // crowi-fileupload-none
 // crowi-fileupload-none
 
 
+const { AbstractFileUploader } = require('./file-uploader');
+
 module.exports = function(crowi) {
 module.exports = function(crowi) {
   const debug = require('debug')('growi:service:fileUploaderNone');
   const debug = require('debug')('growi:service:fileUploaderNone');
-  const Uploader = require('./uploader');
-  const lib = new Uploader(crowi);
+  const lib = new AbstractFileUploader(crowi);
 
 
   lib.getIsUploadable = function() {
   lib.getIsUploadable = function() {
     return false;
     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 { version, configManager, fileUploadService } = this.crowi;
     const userUpperLimit = configManager.getConfig('crowi', 'security:userUpperLimit');
     const userUpperLimit = configManager.getConfig('crowi', 'security:userUpperLimit');
     const fileUploadDisabled = configManager.getConfig('crowi', 'app:fileUploadDisabled');
     const fileUploadDisabled = configManager.getConfig('crowi', 'app:fileUploadDisabled');
-    const fileUploadTotalLimit = configManager.getFileUploadTotalLimit();
+    const fileUploadTotalLimit = fileUploadService.getFileUploadTotalLimit();
     const isWritable = await fileUploadService.isWritable();
     const isWritable = await fileUploadService.isWritable();
 
 
     const attachmentInfo = {
     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 fs from 'graceful-fs';
 import mongoose from 'mongoose';
 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 loggerFactory from '~/utils/logger';
 
 
 import { generateConfigsForInstalling } from '../models/config';
 import { generateConfigsForInstalling } from '../models/config';
 
 
-import ConfigManager from './config-manager';
+import type { ConfigManager } from './config-manager';
 import SearchService from './search';
 import SearchService from './search';
 
 
 const logger = loggerFactory('growi:service:installer');
 const logger = loggerFactory('growi:service:installer');
@@ -146,7 +146,7 @@ export class InstallerService {
         name, username, email, password,
         name, username, email, password,
       } = firstAdminUserToSave;
       } = firstAdminUserToSave;
       adminUser = await User.createUser(name, username, email, password, globalLang);
       adminUser = await User.createUser(name, username, email, password, globalLang);
-      await adminUser.asyncMakeAdmin();
+      await adminUser.asyncGrantAdmin();
     }
     }
     catch (err) {
     catch (err) {
       throw new FailedToCreateAdminUserError(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 { InteractionPayloadAccessor } from '@growi/slack/dist/utils/interaction-payload-accessor';
 import type { RespondUtil } from '@growi/slack/dist/utils/respond-util-factory';
 import type { RespondUtil } from '@growi/slack/dist/utils/respond-util-factory';
 import { generateWebClient } from '@growi/slack/dist/utils/webclient-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 mongoose from 'mongoose';
 
 
 
 
 import loggerFactory from '~/utils/logger';
 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 S2sMessage from '../models/vo/s2s-message';
 import { SlackCommandHandlerError } from '../models/vo/slack-command-handler-error';
 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';
 import { LinkSharedEventHandler } from './slack-event-handler/link-shared';
 
 
 const logger = loggerFactory('growi:service:SlackBotService');
 const logger = loggerFactory('growi:service:SlackBotService');
@@ -230,7 +231,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   }
   }
 
 
   private async postMessageWithLegacyUtil(messageArgs: ChatPostMessageArguments | IncomingWebhookSendArguments): Promise<void> {
   private async postMessageWithLegacyUtil(messageArgs: ChatPostMessageArguments | IncomingWebhookSendArguments): Promise<void> {
-    const slackLegacyUtil = require('../util/slack-legacy')(this.crowi);
+    const slackLegacyUtil = slackLegacyUtilFactory(this.configManager);
 
 
     try {
     try {
       await slackLegacyUtil.postMessage(messageArgs);
       await slackLegacyUtil.postMessage(messageArgs);

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

@@ -1,16 +1,12 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 import type {
 import type {
-  Model, Document, Schema, ConnectOptions,
+  Model, Document, ConnectOptions,
 } from 'mongoose';
 } from 'mongoose';
 
 
 // suppress DeprecationWarning: current Server Discovery and Monitoring engine is deprecated, and will be removed in a future version
 // suppress DeprecationWarning: current Server Discovery and Monitoring engine is deprecated, and will be removed in a future version
 type ConnectionOptionsExtend = {
 type ConnectionOptionsExtend = {
   useUnifiedTopology: boolean
   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 => {
 export const getMongoUri = (): string => {
   const { env } = process;
   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 { pathUtils } from '@growi/core';
 import { selectAll } from 'hast-util-select';
 import { selectAll } from 'hast-util-select';
-import { Plugin } from 'unified';
+import type { Plugin } from 'unified';
 
 
 import {
 import {
-  IAnchorsSelector, IHrefResolver, relativeLinks, RelativeLinksPluginParams,
+  relativeLinks,
+  type IAnchorsSelector, type IUrlResolver, type RelativeLinksPluginParams,
 } from './relative-links';
 } from './relative-links';
 
 
 const customAnchorsSelector: IAnchorsSelector = (node) => {
 const customAnchorsSelector: IAnchorsSelector = (node) => {
   return selectAll('a[href].pukiwiki-like-linker', node);
   return selectAll('a[href].pukiwiki-like-linker', node);
 };
 };
 
 
-const customHrefResolver: IHrefResolver = (relativeHref, basePath) => {
+const customUrlResolver: IUrlResolver = (relativeHref, basePath) => {
   // generate relative pathname
   // generate relative pathname
   const baseUrl = new URL(pathUtils.addTrailingSlash(basePath), 'https://example.com');
   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 = {}) => {
 export const relativeLinksByPukiwikiLikeLinker: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
   return relativeLinks.bind(this)({
   return relativeLinks.bind(this)({
     ...options,
     ...options,
     anchorsSelector: customAnchorsSelector,
     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 isAbsolute from 'is-absolute-url';
-import { Plugin } from 'unified';
+import type { Plugin } from 'unified';
+
 
 
 export type IAnchorsSelector = (node: HastNode) => Element[];
 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) => {
 const defaultAnchorsSelector: IAnchorsSelector = (node) => {
   return selectAll('a[href]', node);
   return selectAll('a[href]', node);
 };
 };
 
 
-const defaultHrefResolver: IHrefResolver = (relativeHref, basePath) => {
+const defaultUrlResolver: IUrlResolver = (relativeHref, basePath) => {
   // generate relative pathname
   // generate relative pathname
   const baseUrl = new URL(basePath, 'https://example.com');
   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 => {
 const isAnchorLink = (href: string): boolean => {
@@ -24,12 +30,12 @@ const isAnchorLink = (href: string): boolean => {
 export type RelativeLinksPluginParams = {
 export type RelativeLinksPluginParams = {
   pagePath?: string,
   pagePath?: string,
   anchorsSelector?: IAnchorsSelector,
   anchorsSelector?: IAnchorsSelector,
-  hrefResolver?: IHrefResolver,
+  urlResolver?: IUrlResolver,
 }
 }
 
 
 export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
 export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
   const anchorsSelector = options.anchorsSelector ?? defaultAnchorsSelector;
   const anchorsSelector = options.anchorsSelector ?? defaultAnchorsSelector;
-  const hrefResolver = options.hrefResolver ?? defaultHrefResolver;
+  const urlResolver = options.urlResolver ?? defaultUrlResolver;
 
 
   return (tree) => {
   return (tree) => {
     if (options.pagePath == null) {
     if (options.pagePath == null) {
@@ -40,16 +46,14 @@ export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {})
     const anchors = anchorsSelector(tree as HastNode);
     const anchors = anchorsSelector(tree as HastNode);
 
 
     anchors.forEach((anchor) => {
     anchors.forEach((anchor) => {
-      if (anchor.properties == null) {
-        return;
-      }
+      assert(anchor.properties != null);
 
 
       const href = anchor.properties.href;
       const href = anchor.properties.href;
       if (href == null || typeof href !== 'string' || isAbsolute(href) || isAnchorLink(href)) {
       if (href == null || typeof href !== 'string' || isAbsolute(href) || isAnchorLink(href)) {
         return;
         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);
+      });
+    });
+  });
+
+});

+ 9 - 5
apps/app/src/stores/modal.tsx

@@ -627,13 +627,17 @@ export const useBookmarkFolderDeleteModal = (status?: DeleteBookmarkFolderModalS
 /*
 /*
  * TemplateModal
  * TemplateModal
  */
  */
-type TemplateModalStatus = {
+
+type TemplateSelectedCallback = (templateText: string) => void;
+type TemplateModalOptions = {
+  onSubmit?: TemplateSelectedCallback,
+}
+type TemplateModalStatus = TemplateModalOptions & {
   isOpened: boolean,
   isOpened: boolean,
-  onSubmit?: (templateText: string) => void
 }
 }
 
 
 type TemplateModalUtils = {
 type TemplateModalUtils = {
-  open(onSubmit: (templateText: string) => void): void,
+  open(opts: TemplateModalOptions): void,
   close(): void,
   close(): void,
 }
 }
 
 
@@ -643,8 +647,8 @@ export const useTemplateModal = (): SWRResponse<TemplateModalStatus, Error> & Te
   const swrResponse = useStaticSWR<TemplateModalStatus, Error>('templateModal', undefined, { fallbackData: initialStatus });
   const swrResponse = useStaticSWR<TemplateModalStatus, Error>('templateModal', undefined, { fallbackData: initialStatus });
 
 
   return Object.assign(swrResponse, {
   return Object.assign(swrResponse, {
-    open: (onSubmit: (templateText: string) => void) => {
-      swrResponse.mutate({ isOpened: true, onSubmit });
+    open: (opts: TemplateModalOptions) => {
+      swrResponse.mutate({ isOpened: true, onSubmit: opts.onSubmit });
     },
     },
     close: () => {
     close: () => {
       swrResponse.mutate({ isOpened: false });
       swrResponse.mutate({ isOpened: false });

+ 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,
+    },
+  );
+};

+ 100 - 20
apps/app/src/stores/template.tsx

@@ -7,43 +7,123 @@ const presetTemplates: ITemplate[] = [
   // preset 1
   // preset 1
   {
   {
     id: '__preset1__',
     id: '__preset1__',
-    name: '[Preset] WESEEK Inner Wiki Style',
-    markdown: `# 関連ページ
+    name: '日報',
+    markdown: `# {{yyyy}}/{{MM}}/{{dd}} 日報
+
+## 今日の目標
+- 目標1
+    - 〇〇の完了
+- 目標2
+    - 〇〇を〇件達成
+
+
+## 内容
+- 10:00 ~ 10:20 今日のタスク確認
+- 10:20 ~ 11:00 全体会議
+
 
 
-$lsx()
+## 進捗
+- 目標1
+    - 完了
+- 目標2
+    - 〇〇件達成
 
 
-# `,
+
+## メモ
+- 改善できることの振り返り
+
+
+## 翌営業日の目標
+- 目標1
+    - 〇〇の完了
+- 目標2
+    - 〇〇を〇件達成
+`,
   },
   },
 
 
   // preset 2
   // preset 2
   {
   {
     id: '__preset2__',
     id: '__preset2__',
-    name: '[Preset] Qiita Style',
-    markdown: `# <会議体名>
+    name: '議事録',
+    markdown: `# {{{title}}}{{^title}}<会議名>{{/title}}
+
 ## 日時
 ## 日時
-yyyy/mm/dd hh:mm〜hh:mm
+{{yyyy}}/{{MM}}/{{dd}} {{HH}}:{{mm}}〜hh:mm
 
 
-## 場所
 
 
-## 出席
+## 参加
 -
 -
 
 
 ## 議題
 ## 議題
-1. [議題1](#link)
+1.
 2.
 2.
-3.
 
 
-## 議事内容
-### <a name="link"></a>議題1
 
 
-## 決定事項
-- 決定事項1
+## 1.
+### 内容
+
+
+### 決定事項
+
+
+### Next Action
+
+
+## 2.
+### 内容
+
+
+### 決定事項
+
+
+### Next Action
+
 
 
-## アクション事項
-- [ ] アクション
+## 次回会議
+- 会議内容
+- 会議時間
+    - {{yyyy}}/{{MM}}/dd
+`,
+  },
+
+  // preset 3
+  {
+    id: '__preset3__',
+    name: '企画書',
+    markdown: `# {{{title}}}{{^title}}<企画タイトル>{{/title}}
+
+## 目的
+
+
+## 現状の課題
+
+
+## 概要
+#### 企画の内容
+
+#### スケジュール
+
+
+## 効果
+#### メリット
+
+#### 数値目標
+
+
+## 参考資料
+
+`,
+  },
+
+  // preset 4
+  {
+    id: '__preset4__',
+    name: '関連ページの一覧表示',
+    markdown: `# 関連ページ
 
 
-## 次回
-yyyy/mm/dd (予定、時間は追って連絡)`,
+## 子ページ一覧
+$lsx(depth=1)
+`,
   },
   },
 ];
 ];
 
 
@@ -52,7 +132,7 @@ export const useTemplates = (): SWRResponse<ITemplate[], Error> => {
     'templates',
     'templates',
     () => [
     () => [
       ...presetTemplates,
       ...presetTemplates,
-      ...Object.values(getGrowiFacade().customTemplates ?? {}),
+      ...Object.values<ITemplate>(getGrowiFacade().customTemplates ?? {}),
     ],
     ],
     {
     {
       fallbackData: presetTemplates,
       fallbackData: presetTemplates,

+ 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', () => {
 describe('validateDeleteConfigs utility function', () => {
   test('Should validate delete configs', () => {
   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', () => {
 describe('To array from csv', () => {
 
 

+ 5 - 0
apps/app/test-with-vite/.eslintrc.js

@@ -0,0 +1,5 @@
+module.exports = {
+  extends: [
+    'plugin:vitest/recommended',
+  ],
+};

+ 26 - 0
apps/app/test-with-vite/setup/mongoms.ts

@@ -0,0 +1,26 @@
+import { MongoMemoryServer } from 'mongodb-memory-server';
+import mongoose from 'mongoose';
+
+import { mongoOptions } from '~/server/util/mongoose-utils';
+
+
+beforeAll(async() => {
+  // set debug flag
+  process.env.MONGOMS_DEBUG = process.env.VITE_MONGOMS_DEBUG;
+
+  // set version
+  const mongoServer = await MongoMemoryServer.create({
+    instance: {
+      dbName: 'growi_test',
+    },
+    binary: {
+      version: process.env.VITE_MONGOMS_VERSION,
+      downloadDir: 'node_modules/.cache/mongodb-binaries',
+    },
+  });
+  await mongoose.connect(mongoServer.getUri(), mongoOptions);
+});
+
+afterAll(async() => {
+  await mongoose.disconnect();
+});

+ 11 - 0
apps/app/test-with-vite/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "$schema": "http://json.schemastore.org/tsconfig",
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "baseUrl": "../",
+    "paths": {
+      "~/*": ["./src/*"],
+      "^/*": ["./*"],
+    }
+  }
+}

+ 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 mongoose from 'mongoose';
 
 
-import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 
 
 // check env
 // check env
 if (process.env.NODE_ENV !== 'test') {
 if (process.env.NODE_ENV !== 'test') {
@@ -15,8 +8,6 @@ if (process.env.NODE_ENV !== 'test') {
 }
 }
 
 
 module.exports = async() => {
 module.exports = async() => {
-  initMongooseGlobalSettings();
-
   mongoose.connect(getMongoUri(), mongoOptions);
   mongoose.connect(getMongoUri(), mongoOptions);
 
 
   // drop database
   // 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 { 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', () => {
 describe('migrate-slack-app-integration-schema', () => {
 
 

+ 0 - 19
apps/app/test/integration/models/config.test.js

@@ -1,19 +0,0 @@
-const { getInstance } = require('../setup-crowi');
-
-describe('Config model test', () => {
-  // eslint-disable-next-line no-unused-vars
-  let crowi;
-
-  beforeAll(async() => {
-    crowi = await getInstance();
-  });
-
-  describe('.CONSTANTS', () => {
-    test('AclService has constants', async() => {
-      expect(crowi.aclService.labels.SECURITY_REGISTRATION_MODE_OPEN).toBe('Open');
-      expect(crowi.aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED).toBe('Restricted');
-      expect(crowi.aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED).toBe('Closed');
-    });
-  });
-
-});

+ 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 mongoose from 'mongoose';
 
 
 import { IProactiveQuestionnaireAnswer } from '../../../src/features/questionnaire/interfaces/proactive-questionnaire-answer';
 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 QuestionnaireOrder from '../../../src/features/questionnaire/server/models/questionnaire-order';
 import { getInstance } from '../setup-crowi';
 import { getInstance } from '../setup-crowi';
 
 
-const axios = require('axios').default;
-
 const spyAxiosGet = jest.spyOn<typeof axios, 'get'>(
 const spyAxiosGet = jest.spyOn<typeof axios, 'get'>(
   axios,
   axios,
   'get',
   'get',

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

@@ -8,14 +8,13 @@
 const gc = require('expose-gc/function');
 const gc = require('expose-gc/function');
 const mongoose = require('mongoose');
 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;
 mongoose.Promise = global.Promise;
 
 
 jest.setTimeout(30000); // default 5000
 jest.setTimeout(30000); // default 5000
 
 
 beforeAll(async() => {
 beforeAll(async() => {
-  initMongooseGlobalSettings();
   await mongoose.connect(getMongoUri(), mongoOptions);
   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);
-
-    });
-  });
-
-});

+ 4 - 1
apps/app/tsconfig.json

@@ -6,6 +6,9 @@
 
 
     "jsx": "preserve",
     "jsx": "preserve",
     "resolveJsonModule": true,
     "resolveJsonModule": true,
+    "types": [
+      "vitest/globals"
+    ],
 
 
     "baseUrl": ".",
     "baseUrl": ".",
     "paths": {
     "paths": {
@@ -18,7 +21,7 @@
   "include": [
   "include": [
     "next-env.d.ts",
     "next-env.d.ts",
     "config",
     "config",
-    "src"
+    "src",
   ],
   ],
   "ts-node": {
   "ts-node": {
     "transpileOnly": true,
     "transpileOnly": true,

+ 22 - 0
apps/app/vitest.config.integ.ts

@@ -0,0 +1,22 @@
+import { defineConfig, mergeConfig } from 'vitest/config';
+
+import configShared from './vitest.config';
+
+export default mergeConfig(
+  configShared,
+  defineConfig({
+    test: {
+      include: [
+        '**/*.integ.ts',
+      ],
+      setupFiles: [
+        './test-with-vite/setup/mongoms.ts',
+      ],
+      coverage: {
+        exclude: [
+          '**/*{.,-}integ.ts',
+        ],
+      },
+    },
+  }),
+);

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

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

+ 0 - 7
apps/app/vitest.config.unit.ts

@@ -1,7 +0,0 @@
-import { defineProject } from 'vitest/config';
-
-export default defineProject({
-  test: {
-    environment: 'node',
-  },
-});

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

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "6.1.2-slackbot-proxy.0",
+  "version": "6.1.3-slackbot-proxy.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^6.1.2-RC.0",
+    "@growi/slack": "^6.1.3-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",
     "@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**
 > **Warning**
 > **Migration in this way is applied only to the latest revision. Past revisions are not applied.**
 > **Migration in this way is applied only to the latest revision. Past revisions are not applied.**
@@ -6,7 +6,7 @@
 ## Usage
 ## Usage
 ```
 ```
 git clone https://github.com/weseek/growi
 git clone https://github.com/weseek/growi
-cd growi/bin/data-migrations/v6
+cd growi/bin/data-migrations
 
 
 NETWORK=growi_devcontainer_default \
 NETWORK=growi_devcontainer_default \
 MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi \
 MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi \
@@ -14,9 +14,9 @@ docker run --rm \
   --network $NETWORK \
   --network $NETWORK \
   -v "$(pwd)"/src:/opt \
   -v "$(pwd)"/src:/opt \
   -w /opt \
   -w /opt \
-  -e MIGRATION_TYPE=v6 \
+  -e MIGRATION_MODULE=v60x \
   mongo:6.0 \
   mongo:6.0 \
-  /bin/mongosh $MONGO_URI migration.js
+  /bin/mongosh $MONGO_URI index.js
 ```
 ```
 
 
 ## Variables
 ## Variables
@@ -30,20 +30,22 @@ docker run --rm \
 
 
 | Variable              | Description                                                                    | Default |
 | 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))
 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))
 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))
 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))
 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))
 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)
 - `custom`: You can define your own processors and apply them to `revision` (see "Advanced" below for details)
 
 
 ### Optional
 ### Optional
@@ -57,21 +59,23 @@ reference](https://docs.growi.org/ja/admin-guide/upgrading/60x.html#%E6%9C%AA%E5
 
 
 ## Advanced
 ## 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`.
 The following function replaces the string `foo` with the string `bar`.
 
 
 ``` javascript
 ``` javascript
-function customProcessor(body) {
+module.exports = [
+  (body) => {
   var fooRegExp = /foo/g; // foo regex
   var fooRegExp = /foo/g; // foo regex
   return body.replace(fooRegExp, 'bar'); // replace to bar
   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
 git clone https://github.com/weseek/growi
-cd growi/bin/data-migrations/v6
+cd growi/bin/data-migrations
 
 
 NETWORK=growi_devcontainer_default \
 NETWORK=growi_devcontainer_default \
 MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi \
 MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi \
@@ -79,7 +83,7 @@ docker run --rm \
   --network $NETWORK \
   --network $NETWORK \
   -v "$(pwd)"/src:/opt \
   -v "$(pwd)"/src:/opt \
   -w /opt \
   -w /opt \
-  -e MIGRATION_TYPE=custom \
+  -e MIGRATION_MODULE=custom \
   mongo:6.0 \
   mongo:6.0 \
-  /bin/mongosh $MONGO_URI migration.js
+  /bin/mongosh $MONGO_URI index.js
 ```
 ```

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

@@ -2,38 +2,40 @@
 /* eslint-disable no-undef, no-var, vars-on-top, no-restricted-globals, regex/invalid, import/extensions */
 /* eslint-disable no-undef, no-var, vars-on-top, no-restricted-globals, regex/invalid, import/extensions */
 // ignore lint error because this file is js as mongoshell
 // ignore lint error because this file is js as mongoshell
 
 
+/**
+ * @typedef {import('./types').MigrationModule} MigrationModule
+ * @typedef {import('./types').ReplaceLatestRevisions} ReplaceLatestRevisions
+ */
+
 var pagesCollection = db.getCollection('pages');
 var pagesCollection = db.getCollection('pages');
 var revisionsCollection = db.getCollection('revisions');
 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 = [];
+/** @type {MigrationModule[]} */
+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) {
+/** @type {ReplaceLatestRevisions} */
+function replaceLatestRevisions(body, migrationModules) {
   var replacedBody = body;
   var replacedBody = body;
-  processors.forEach((processor) => {
-    replacedBody = processor(replacedBody);
+  migrationModules.forEach((migrationModule) => {
+    replacedBody = migrationModule(replacedBody);
   });
   });
   return 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) => {
 pagesCollection.find({}).forEach((doc) => {
   if (doc.revision) {
   if (doc.revision) {
     var revision = revisionsCollection.findOne({ _id: doc.revision });
     var revision = revisionsCollection.findOne({ _id: doc.revision });
-    var replacedBody = replaceLatestRevisions(revision.body, [...processors]);
+    var replacedBody = replaceLatestRevisions(revision.body, [...migrationModules]);
     var operation = {
     var operation = {
       updateOne: {
       updateOne: {
         filter: { _id: revision._id },
         filter: { _id: revision._id },
@@ -54,4 +56,5 @@ pagesCollection.find({}).forEach((doc) => {
   }
   }
 });
 });
 revisionsCollection.bulkWrite(operations);
 revisionsCollection.bulkWrite(operations);
+
 print('migration complete!');
 print('migration complete!');

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

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

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

@@ -0,0 +1,15 @@
+/**
+ * @typedef {import('../../types').MigrationModule} MigrationModule
+ */
+
+module.exports = [
+  /**
+   * @type {MigrationModule}
+   */
+  (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]]');
+  },
+];

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

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

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

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

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

@@ -0,0 +1,6 @@
+const bracketlink = require('./bracketlink');
+const csv = require('./csv');
+const plantUML = require('./plantuml');
+const tsv = require('./tsv');
+
+module.exports = [...bracketlink, ...csv, ...plantUML, ...tsv];

+ 13 - 0
bin/data-migrations/src/migrations/v60x/plantuml.js

@@ -0,0 +1,13 @@
+/**
+ * @typedef {import('../../types').MigrationModule} MigrationModule
+ */
+
+module.exports = [
+  /**
+   * @type {MigrationModule}
+   */
+  (body) => {
+    const oldPlantUmlRegExp = /@startuml\n([\s\S]*?)\n@enduml/g; // plantUML old format
+    return body.replace(oldPlantUmlRegExp, '``` plantuml\n$1\n```');
+  },
+];

+ 13 - 0
bin/data-migrations/src/migrations/v60x/tsv.js

@@ -0,0 +1,13 @@
+/**
+ * @typedef {import('../../types').MigrationModule} MigrationModule
+ */
+
+module.exports = [
+  /**
+   * @type {MigrationModule}
+   */
+  (body) => {
+    const oldTsvTableRegExp = /::: tsv(-h)?\n([\s\S]*?)\n:::/g; // TSV old format
+    return body.replace(oldTsvTableRegExp, '``` tsv$1\n$2\n```');
+  },
+];

+ 3 - 0
bin/data-migrations/src/migrations/v61x/index.js

@@ -0,0 +1,3 @@
+const mdcont = require('./mdcont');
+
+module.exports = [...mdcont];

+ 13 - 0
bin/data-migrations/src/migrations/v61x/mdcont.js

@@ -0,0 +1,13 @@
+/**
+ * @typedef {import('../../types').MigrationModule} MigrationModule
+ */
+
+module.exports = [
+  /**
+   * @type {MigrationModule}
+   */
+  (body) => {
+    const oldMdcontPrefixRegExp = /#mdcont-/g;
+    return body.replace(oldMdcontPrefixRegExp, '#');
+  },
+];

+ 2 - 0
bin/data-migrations/src/types.d.ts

@@ -0,0 +1,2 @@
+export type MigrationModule = (body: string) => string;
+export type ReplaceLatestRevisions = (body: string, migrationModules: MigrationModule[]) => string;

+ 0 - 83
bin/data-migrations/v6/src/processor.js

@@ -1,83 +0,0 @@
-
-/* eslint-disable no-undef, no-var, vars-on-top, no-restricted-globals, regex/invalid */
-// ignore lint error because this file is js as mongoshell
-
-// ===========================================
-// processors for old format
-// ===========================================
-function drawioProcessor(body) {
-  var oldDrawioRegExp = /:::\s?drawio\n(.+?)\n:::/g; // drawio old format
-  return body.replace(oldDrawioRegExp, '``` drawio\n$1\n```');
-}
-
-function plantumlProcessor(body) {
-  var oldPlantUmlRegExp = /@startuml\n([\s\S]*?)\n@enduml/g; // plantUML old format
-  return body.replace(oldPlantUmlRegExp, '``` plantuml\n$1\n```');
-}
-
-function tsvProcessor(body) {
-  var oldTsvTableRegExp = /::: tsv(-h)?\n([\s\S]*?)\n:::/g; // TSV old format
-  return body.replace(oldTsvTableRegExp, '``` tsv$1\n$2\n```');
-}
-
-function csvProcessor(body) {
-  var oldCsvTableRegExp = /::: csv(-h)?\n([\s\S]*?)\n:::/g; // CSV old format
-  return body.replace(oldCsvTableRegExp, '``` csv$1\n$2\n```');
-}
-
-function bracketlinkProcessor(body) {
-  // https://regex101.com/r/btZ4hc/1
-  var oldBracketLinkRegExp = /(?<!\[)\[{1}(\/.*?)\]{1}(?!\])/g; // Page Link old format
-  return body.replace(oldBracketLinkRegExp, '[[$1]]');
-}
-
-function mdcontPrefixProcessor(body) {
-  var oldMdcontPrefixRegExp = /#mdcont-/g;
-  return body.replace(oldMdcontPrefixRegExp, '#');
-}
-
-// processor for MIGRATION_TYPE=custom
-function customProcessor(body) {
-  // ADD YOUR PROCESS HERE!
-  // https://github.com/weseek/growi/discussions/7180
-  return body;
-}
-
-// ===========================================
-// define processors
-// ===========================================
-
-function getProcessorArray(migrationType) {
-  var oldFormatProcessors;
-  switch (migrationType) {
-    case 'v6-drawio':
-      oldFormatProcessors = [drawioProcessor];
-      break;
-    case 'v6-plantuml':
-      oldFormatProcessors = [plantumlProcessor];
-      break;
-    case 'v6-tsv':
-      oldFormatProcessors = [tsvProcessor];
-      break;
-    case 'v6-csv':
-      oldFormatProcessors = [csvProcessor];
-      break;
-    case 'v6-bracketlink':
-      oldFormatProcessors = [bracketlinkProcessor];
-      break;
-    case 'mdcont':
-      oldFormatProcessors = [mdcontPrefixProcessor];
-      break;
-    case 'v6':
-      oldFormatProcessors = [drawioProcessor, plantumlProcessor, tsvProcessor, csvProcessor, bracketlinkProcessor];
-      break;
-    case 'custom':
-      oldFormatProcessors = [customProcessor];
-      break;
-    default:
-      oldFormatProcessors = [];
-  }
-  return oldFormatProcessors;
-}
-
-module.exports = getProcessorArray;

+ 9 - 3
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "6.1.2-RC.0",
+  "version": "6.1.3-RC.0",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -68,6 +68,8 @@
     "@typescript-eslint/eslint-plugin": "^5.59.7",
     "@typescript-eslint/eslint-plugin": "^5.59.7",
     "@typescript-eslint/parser": "^5.59.7",
     "@typescript-eslint/parser": "^5.59.7",
     "@vitejs/plugin-react": "^3.1.0",
     "@vitejs/plugin-react": "^3.1.0",
+    "@vitest/coverage-c8": "^0.31.1",
+    "@vitest/ui": "^0.31.1",
     "cypress": "^12.0.1",
     "cypress": "^12.0.1",
     "cypress-wait-until": "^1.7.2",
     "cypress-wait-until": "^1.7.2",
     "eslint": "^8.41.0",
     "eslint": "^8.41.0",
@@ -79,10 +81,12 @@
     "eslint-plugin-react": "^7.30.1",
     "eslint-plugin-react": "^7.30.1",
     "eslint-plugin-react-hooks": "^4.6.0",
     "eslint-plugin-react-hooks": "^4.6.0",
     "eslint-plugin-rulesdir": "^0.2.2",
     "eslint-plugin-rulesdir": "^0.2.2",
+    "eslint-plugin-vitest": "^0.2.3",
     "glob": "^8.1.0",
     "glob": "^8.1.0",
     "jest": "^28.1.3",
     "jest": "^28.1.3",
     "jest-date-mock": "^1.0.8",
     "jest-date-mock": "^1.0.8",
     "jest-localstorage-mock": "^2.4.14",
     "jest-localstorage-mock": "^2.4.14",
+    "mock-require": "^3.0.3",
     "postcss": "^8.4.5",
     "postcss": "^8.4.5",
     "postcss-scss": "^4.0.3",
     "postcss-scss": "^4.0.3",
     "reg-keygen-git-hash-plugin": "^0.11.1",
     "reg-keygen-git-hash-plugin": "^0.11.1",
@@ -97,9 +101,11 @@
     "tsconfig-paths": "^3.9.0",
     "tsconfig-paths": "^3.9.0",
     "typescript": "~4.9",
     "typescript": "~4.9",
     "unplugin-swc": "^1.3.2",
     "unplugin-swc": "^1.3.2",
-    "vite": "^4.2.2",
+    "vite": "^4.3.8",
     "vite-plugin-dts": "^2.0.0-beta.0",
     "vite-plugin-dts": "^2.0.0-beta.0",
-    "vitest": "^0.31.1"
+    "vite-tsconfig-paths": "^4.2.0",
+    "vitest": "^0.31.1",
+    "vitest-mock-extended": "^1.1.3"
   },
   },
   "engines": {
   "engines": {
     "node": "^16 || ^18",
     "node": "^16 || ^18",

+ 5 - 0
packages/core/.eslintrc.js

@@ -0,0 +1,5 @@
+module.exports = {
+  extends: [
+    'plugin:vitest/recommended',
+  ],
+};

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