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

Merge pull request #8867 from weseek/master

Release v7.0.10
Yuki Takei 1 год назад
Родитель
Сommit
e6786cb16a
100 измененных файлов с 1237 добавлено и 1146 удалено
  1. 2 1
      .devcontainer/devcontainer.json
  2. 5 1
      .github/dependabot.yml
  3. 2 2
      .github/workflows/ci-app-prod.yml
  4. 27 51
      .github/workflows/ci-app.yml
  5. 2 2
      .github/workflows/ci-slackbot-proxy.yml
  6. 136 26
      .github/workflows/reusable-app-prod.yml
  7. 5 15
      .github/workflows/reusable-app-reg-suit.yml
  8. 1 1
      apps/app/docker/Dockerfile
  9. 1 1
      apps/app/next.config.js
  10. 9 0
      apps/app/nodemon.json
  11. 6 7
      apps/app/package.json
  12. 110 0
      apps/app/playwright.config.ts
  13. 0 0
      apps/app/playwright/.auth/.gitkeep
  14. 16 0
      apps/app/playwright/.eslintrc.mjs
  15. 2 0
      apps/app/playwright/.gitignore
  16. 47 0
      apps/app/playwright/10-installer/install.spec.ts
  17. 22 0
      apps/app/playwright/20-basic-features/access-to-page.spec.ts
  18. 27 0
      apps/app/playwright/20-basic-features/create-page-button.spec.ts
  19. 97 0
      apps/app/playwright/40-admin/access-to-admin-page.spec.ts
  20. 24 0
      apps/app/playwright/auth.setup.ts
  21. 7 2
      apps/app/src/client/services/create-page/use-create-page.tsx
  22. 6 6
      apps/app/src/client/services/renderer/renderer.tsx
  23. 2 2
      apps/app/src/components/Admin/App/AppSetting.jsx
  24. 1 1
      apps/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx
  25. 0 96
      apps/app/src/components/Admin/MarkdownSetting/WhitelistInput.jsx
  26. 79 0
      apps/app/src/components/Admin/MarkdownSetting/WhitelistInput.tsx
  27. 10 10
      apps/app/src/components/Admin/MarkdownSetting/XssForm.jsx
  28. 4 6
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  29. 3 7
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.tsx
  30. 1 1
      apps/app/src/components/Common/SubmittableInput/AutosizeSubmittableInput.tsx
  31. 9 1
      apps/app/src/components/Common/SubmittableInput/use-submittable.ts
  32. 1 1
      apps/app/src/components/CreateTemplateModal.tsx
  33. 18 19
      apps/app/src/components/InstallerForm.tsx
  34. 9 7
      apps/app/src/components/LoginForm/LoginForm.tsx
  35. 3 1
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  36. 12 11
      apps/app/src/components/PageControls/PageControls.tsx
  37. 1 1
      apps/app/src/components/PageEditor/EditorNavbar/EditingUserList.tsx
  38. 19 6
      apps/app/src/components/PageEditor/OptionsSelector.tsx
  39. 4 3
      apps/app/src/components/PageEditor/PageEditor.tsx
  40. 12 8
      apps/app/src/components/PageEditor/page-path-rename-utils.ts
  41. 11 16
      apps/app/src/components/PageHeader/PageTitleHeader.tsx
  42. 12 10
      apps/app/src/components/PageSelectModal/PageSelectModal.tsx
  43. 20 0
      apps/app/src/components/PageSelectModal/TreeItemForModal.module.scss
  44. 13 15
      apps/app/src/components/PageSelectModal/TreeItemForModal.tsx
  45. 18 4
      apps/app/src/components/ReactMarkdownComponents/Header.module.scss
  46. 14 9
      apps/app/src/components/ReactMarkdownComponents/Header.tsx
  47. 8 8
      apps/app/src/components/Script/DrawioViewerScript/DrawioViewerScript.tsx
  48. 1 0
      apps/app/src/components/Script/DrawioViewerScript/index.ts
  49. 17 0
      apps/app/src/components/Script/DrawioViewerScript/use-viewer-min-js-url.spec.ts
  50. 9 0
      apps/app/src/components/Script/DrawioViewerScript/use-viewer-min-js-url.ts
  51. 1 1
      apps/app/src/components/Sidebar/PageCreateButton/CreateButton.tsx
  52. 1 0
      apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx
  53. 2 1
      apps/app/src/components/Sidebar/PageCreateButton/DropendToggle.tsx
  54. 16 12
      apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx
  55. 8 3
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  56. 7 8
      apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  57. 9 27
      apps/app/src/components/TreeItem/TreeItemLayout.tsx
  58. 9 17
      apps/app/src/features/growi-plugin/server/models/vo/github-url.ts
  59. 1 1
      apps/app/src/interfaces/page.ts
  60. 0 6
      apps/app/src/interfaces/rehype.ts
  61. 15 0
      apps/app/src/interfaces/services/rehype-sanitize.ts
  62. 2 2
      apps/app/src/interfaces/services/renderer.ts
  63. 4 4
      apps/app/src/pages/[[...path]].page.tsx
  64. 4 4
      apps/app/src/pages/_private-legacy-pages.page.tsx
  65. 4 4
      apps/app/src/pages/_search.page.tsx
  66. 3 3
      apps/app/src/pages/me/[[...path]].page.tsx
  67. 5 5
      apps/app/src/pages/share/[[...path]].page.tsx
  68. 0 10
      apps/app/src/server/.node-dev.json
  69. 1 15
      apps/app/src/server/crowi/index.js
  70. 3 2
      apps/app/src/server/models/config.ts
  71. 3 20
      apps/app/src/server/routes/apiv3/page/update-page.ts
  72. 3 2
      apps/app/src/server/routes/apiv3/user-group.js
  73. 0 13
      apps/app/src/server/routes/page.js
  74. 1 28
      apps/app/src/server/service/customize.ts
  75. 2 4
      apps/app/src/server/service/file-uploader/aws.ts
  76. 8 16
      apps/app/src/server/service/page/index.ts
  77. 2 1
      apps/app/src/server/service/slack-command-handler/create-page-service.js
  78. 0 73
      apps/app/src/server/service/xss.js
  79. 39 0
      apps/app/src/services/general-xss-filter/general-xss-filter.spec.ts
  80. 37 0
      apps/app/src/services/general-xss-filter/general-xss-filter.ts
  81. 1 0
      apps/app/src/services/general-xss-filter/index.ts
  82. 38 0
      apps/app/src/services/renderer/recommended-whitelist.spec.ts
  83. 29 0
      apps/app/src/services/renderer/recommended-whitelist.ts
  84. 12 23
      apps/app/src/services/renderer/renderer.tsx
  85. 0 42
      apps/app/src/services/xss/commonmark-spec.js
  86. 0 63
      apps/app/src/services/xss/index.js
  87. 0 21
      apps/app/src/services/xss/recommended-whitelist.js
  88. 0 32
      apps/app/src/services/xss/xssOption.ts
  89. 13 0
      apps/app/src/stores/ui.tsx
  90. 0 10
      apps/app/src/stores/xss.ts
  91. 12 0
      apps/app/src/styles/_layout.scss
  92. 58 26
      apps/app/src/styles/organisms/_wiki.scss
  93. 0 58
      apps/app/test/cypress/e2e/10-install/10-install--install.cy.ts
  94. 0 38
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts
  95. 0 47
      apps/app/test/cypress/e2e/23-editor/23-editor--saving.cy.ts
  96. 0 124
      apps/app/test/cypress/e2e/40-admin/40-admin--access-to-admin-page.cy.ts
  97. 0 3
      apps/app/test/integration/service/page-grant.test.ts
  98. 9 8
      apps/app/test/integration/service/page.test.js
  99. 10 9
      apps/app/test/integration/service/v5.non-public-page.test.ts
  100. 0 2
      apps/app/test/integration/service/v5.page.test.ts

+ 2 - 1
.devcontainer/devcontainer.json

@@ -26,7 +26,8 @@
     "esbenp.prettier-vscode",
     "shinnn.stylelint",
     "stylelint.vscode-stylelint",
-    "vitest.explorer"
+    "vitest.explorer",
+    "ms-playwright.playwright"
   ],
 
   // Uncomment the next line if you want start specific services in your Docker Compose config.

+ 5 - 1
.github/dependabot.yml

@@ -4,7 +4,8 @@ updates:
     directory: '/'
     open-pull-requests-limit: 3
     schedule:
-      interval: monthly
+      interval: weekly
+      day: saturday
     labels:
       - "type/dependencies"
     commit-message:
@@ -16,6 +17,7 @@ updates:
     open-pull-requests-limit: 3
     schedule:
       interval: weekly
+      day: saturday
     labels:
       - "type/dependencies"
     commit-message:
@@ -27,4 +29,6 @@ updates:
       - dependency-name: "@handsontable/react"
       - dependency-name: handsontable
       - dependency-name: typeorm
+      - dependency-name: mysql2
+
 

+ 2 - 2
.github/workflows/ci-app-prod.yml

@@ -52,7 +52,7 @@ jobs:
     uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
     with:
       node-version: 18.x
-      skip-cypress: true
+      skip-e2e-test: true
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
@@ -61,7 +61,7 @@ jobs:
     uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
     with:
       node-version: 20.x
-      skip-cypress: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
+      skip-e2e-test: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
       cypress-report-artifact-name-prefix: cypress-report-
       cypress-config-video: ${{ inputs.cypress-config-video || false }}
     secrets:

+ 27 - 51
.github/workflows/ci-app.yml

@@ -43,20 +43,21 @@ jobs:
         with:
           path: |
             **/node_modules
-          key: node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('apps/app/package.json') }}
+            !**/node_modules/.cache/turbo
+          key: node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
           restore-keys: |
-            node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
-            node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-
+            node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-
 
-      - name: Restore dist
-        uses: actions/cache/restore@v4
+      - name: Cache/Restore dist
+        uses: actions/cache@v4
         with:
           path: |
             **/.turbo
             **/dist
-          key: dist-app-7.x-ci-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
+            **/node_modules/.cache/turbo
+          key: dist-ci-app-${{ runner.OS }}-node${{ matrix.node-version }}-${{ github.sha }}
           restore-keys: |
-            dist-app-7.x-ci-${{ runner.OS }}-node${{ matrix.node-version }}-
+            dist-ci-app-${{ runner.OS }}-node${{ matrix.node-version }}-
 
       - name: Install dependencies
         run: |
@@ -78,14 +79,6 @@ jobs:
           isCompactMode: true
           url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
-      - name: Cache dist
-        uses: actions/cache/save@v4
-        with:
-          path: |
-            **/.turbo
-            **/dist
-          key: dist-app-7.x-ci-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
-
 
   ci-app-test:
     runs-on: ubuntu-latest
@@ -110,25 +103,25 @@ jobs:
           cache-dependency-path: '**/yarn.lock'
 
       - name: Cache/Restore node_modules
-        id: cache-dependencies
         uses: actions/cache@v4
         with:
           path: |
             **/node_modules
-          key: node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('apps/app/package.json') }}
+            !**/node_modules/.cache/turbo
+          key: node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
           restore-keys: |
-            node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
-            node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-
+            node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-
 
-      - name: Restore dist
-        uses: actions/cache/restore@v4
+      - name: Cache/Restore dist
+        uses: actions/cache@v4
         with:
           path: |
             **/.turbo
             **/dist
-          key: dist-app-7.x-ci-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
+            **/node_modules/.cache/turbo
+          key: dist-ci-app-${{ runner.OS }}-node${{ matrix.node-version }}-${{ github.sha }}
           restore-keys: |
-            dist-app-7.x-ci-${{ runner.OS }}-node${{ matrix.node-version }}-
+            dist-ci-app-${{ runner.OS }}-node${{ matrix.node-version }}-
 
       - name: Install dependencies
         run: |
@@ -138,7 +131,7 @@ jobs:
 
       - name: Test
         run: |
-          turbo run test --filter=!@growi/slackbot-proxy
+          turbo run test --filter=!@growi/slackbot-proxy --env-mode=loose
         env:
           MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
 
@@ -160,14 +153,6 @@ jobs:
           isCompactMode: true
           url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
-      - name: Cache dist
-        uses: actions/cache/save@v4
-        with:
-          path: |
-            **/.turbo
-            **/dist
-          key: dist-app-7.x-ci-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
-
 
   ci-app-launch-dev:
     runs-on: ubuntu-latest
@@ -192,26 +177,26 @@ jobs:
           cache-dependency-path: '**/yarn.lock'
 
       - name: Cache/Restore node_modules
-        id: cache-dependencies
         uses: actions/cache@v4
         with:
           path: |
             **/node_modules
-          key: node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('apps/app/package.json') }}
+            !**/node_modules/.cache/turbo
+          key: node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
           restore-keys: |
-            node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
-            node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-
+            node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-
+            node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-
 
-      - name: Restore dist
-        uses: actions/cache/restore@v4
+      - name: Cache/Restore dist
+        uses: actions/cache@v4
         with:
           path: |
             **/.turbo
             **/dist
-            ${{ github.workspace }}/apps/app/.next
-          key: dist-app-7.x-dev-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
+            **/node_modules/.cache/turbo
+          key: dist-ci-app-${{ runner.OS }}-node${{ matrix.node-version }}-${{ github.sha }}
           restore-keys: |
-            dist-app-7.x-dev-${{ runner.OS }}-node${{ matrix.node-version }}-
+            dist-ci-app-${{ runner.OS }}-node${{ matrix.node-version }}-
 
       - name: Install dependencies
         run: |
@@ -223,7 +208,7 @@ jobs:
         working-directory: ./apps/app
         run: |
           cp config/ci/.env.local.for-ci .env.development.local
-          turbo run dev:ci
+          turbo run dev:ci --env-mode=loose
         env:
           MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_dev
 
@@ -236,12 +221,3 @@ jobs:
           channel: '#ci'
           isCompactMode: true
           url: ${{ secrets.SLACK_WEBHOOK_URL }}
-
-      - name: Cache dist
-        uses: actions/cache/save@v4
-        with:
-          path: |
-            **/.turbo
-            **/dist
-            ${{ github.workspace }}/apps/app/.next
-          key: dist-app-7.x-dev-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}

+ 2 - 2
.github/workflows/ci-slackbot-proxy.yml

@@ -145,7 +145,7 @@ jobs:
       working-directory: ./apps/slackbot-proxy
       run: |
         cp config/ci/.env.local.for-ci .env.development.local
-        turbo run dev:ci
+        turbo run dev:ci --env-mode=loose
       env:
         SERVER_URI: http://localhost:8080
         TYPEORM_CONNECTION: mysql
@@ -206,7 +206,7 @@ jobs:
 
     - name: Prune repositories
       run: |
-        turbo prune --scope=@growi/slackbot-proxy
+        turbo prune @growi/slackbot-proxy
         rm -rf apps packages
         mv out/* .
 

+ 136 - 26
.github/workflows/reusable-app-prod.yml

@@ -6,7 +6,7 @@ on:
       node-version:
         required: true
         type: string
-      skip-cypress:
+      skip-e2e-test:
         type: boolean
       cypress-report-artifact-name-prefix:
         type: string
@@ -43,44 +43,43 @@ jobs:
 
     - name: Prune repositories
       run: |
-        turbo prune --scope=@growi/app
+        turbo prune @growi/app
         rm -rf apps packages
         mv out/* .
 
     - name: Cache/Restore node_modules
-      id: cache-dependencies
       uses: actions/cache@v4
       with:
         path: |
           **/node_modules
-        key: node_modules-app-7.x-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+          !**/node_modules/.cache/turbo
+        key: node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
         restore-keys: |
-          node_modules-app-7.x-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
+          node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: Install dependencies
       run: |
         yarn global add node-gyp
         yarn --frozen-lockfile
 
-    - name: Restore dist
+    - name: Cache/Restore dist
       uses: actions/cache@v4
       with:
         path: |
-          node_modules/.cache/turbo
           **/.turbo
           **/dist
+          **/node_modules/.cache/turbo
           ${{ github.workspace }}/apps/app/.next
-        key: dist-app-7.x-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ github.ref_name }}-${{ github.sha }}
+        key: dist-app-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ github.sha }}
         restore-keys: |
-          dist-app-7.x-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ github.ref_name }}-
-          dist-app-7.x-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
+          dist-app-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: Build
       working-directory: ./apps/app
       run: |
-        turbo run build
+        turbo run build --env-mode=loose
       env:
-        ANALYZE_BUNDLE_SIZE: 1
+        ANALYZE: 1
 
     - name: Archive production files
       id: archive-prod-files
@@ -155,19 +154,19 @@ jobs:
 
     - name: Prune repositories
       run: |
-        turbo prune --scope=@growi/app
+        turbo prune @growi/app
         rm -rf apps packages
         mv out/* .
 
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v4
+    - name: Restore node_modules
+      uses: actions/cache/restore@v4
       with:
         path: |
           **/node_modules
-        key: node_modules-app-7.x-launch-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        # shared key with build-prod
+        key: node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
         restore-keys: |
-          node_modules-app-7.x-launch-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
+          node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: Install dependencies
       run: |
@@ -206,7 +205,7 @@ jobs:
   run-cypress:
     needs: [build-prod]
 
-    if: ${{ !inputs.skip-cypress }}
+    if: ${{ !inputs.skip-e2e-test }}
 
     runs-on: ubuntu-latest
 
@@ -214,7 +213,7 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['10', '20', '21', '22', '23', '30', '40', '50', '60']
+        spec-group: ['20', '21', '22', '23', '30', '50', '60']
 
     services:
       mongodb:
@@ -246,19 +245,19 @@ jobs:
 
     - name: Prune repositories
       run: |
-        turbo prune --scope=@growi/app
+        turbo prune @growi/app
         rm -rf apps packages
         mv out/* .
 
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v4
+    - name: Restore node_modules
+      uses: actions/cache/restore@v4
       with:
         path: |
           **/node_modules
-        key: node_modules-app-7.x-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        # saved key by build-prod
+        key: node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
         restore-keys: |
-          node_modules-app-7.x-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
+          node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: Cache/Restore Cypress files
       uses: actions/cache@v4
@@ -339,3 +338,114 @@ jobs:
         channel: '#ci'
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+
+  run-playwright:
+    needs: [build-prod]
+
+    if: ${{ !inputs.skip-e2e-test }}
+
+    runs-on: ubuntu-latest
+    container:
+      image: mcr.microsoft.com/playwright:latest
+
+    strategy:
+      fail-fast: false
+      matrix:
+        browser: [chromium, firefox, webkit]
+        shard: [1/2, 2/2]
+
+    services:
+      mongodb:
+        image: mongo:6.0
+        ports:
+        - 27017/tcp
+      elasticsearch:
+        image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1
+        ports:
+        - 9200/tcp
+        env:
+          discovery.type: single-node
+
+    steps:
+    - uses: actions/checkout@v4
+
+    - uses: actions/setup-node@v4
+      with:
+        node-version: ${{ inputs.node-version }}
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Restore node_modules
+      uses: actions/cache/restore@v4
+      with:
+        path: |
+          **/node_modules
+        # saved key by build-prod
+        key: node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
+
+    - name: Install dependencies
+      run: |
+        yarn global add node-gyp
+        yarn --frozen-lockfile
+
+    - name: Install Playwright browsers
+      run: |
+        yarn playwright install --with-deps ${{ matrix.browser }}
+
+    - name: Download production files artifact
+      uses: actions/download-artifact@v4
+      with:
+        name: Production Files (node${{ inputs.node-version }})
+
+    - name: Extract procution files artifact
+      run: |
+        tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
+
+    - name: Copy dotenv file for ci
+      working-directory: ./apps/app
+      run: |
+        cat config/ci/.env.local.for-ci >> .env.production.local
+
+    - name: Playwright Run (--project=chromium/installer)
+      if: ${{ matrix.browser == 'chromium' }}
+      working-directory: ./apps/app
+      run: |
+        yarn playwright test --project=chromium/installer
+      env:
+        HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
+        MONGO_URI: mongodb://mongodb:27017/growi-playwright-installer
+        ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
+
+    - name: Copy dotenv file for automatic installation
+      working-directory: ./apps/app
+      run: |
+        cat config/ci/.env.local.for-auto-install >> .env.production.local
+
+    # - name: Copy dotenv file for automatic installation with allowing guest mode
+    #   if: ${{ matrix.spec-group == '21' }}
+    #   working-directory: ./apps/app
+    #   run: |
+    #     cat config/ci/.env.local.for-auto-install-with-allowing-guest >> .env.production.local
+
+    - name: Playwright Run
+      working-directory: ./apps/app
+      run: |
+        yarn playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }}
+      env:
+        HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
+        MONGO_URI: mongodb://mongodb:27017/growi-playwright
+        ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi - run-playwright*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 5 - 15
.github/workflows/reusable-app-reg-suit.yml

@@ -60,25 +60,15 @@ jobs:
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
-    - name: Install turbo
-      run: |
-        yarn global add turbo
-
-    - name: Prune repositories
-      run: |
-        turbo prune --scope=@growi/app
-        rm -rf apps packages
-        mv out/* .
-
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v4
+    - name: Restore node_modules
+      uses: actions/cache/restore@v4
       with:
         path: |
           **/node_modules
-        key: node_modules-7.x-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        # saved key by launch-prod
+        key: node_modules-app-launch-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
         restore-keys: |
-          node_modules-7.x-${{ runner.OS }}-node${{ inputs.node-version }}-
+          node_modules-app-launch-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: Install dependencies
       run: |

+ 1 - 1
apps/app/docker/Dockerfile

@@ -12,7 +12,7 @@ WORKDIR ${optDir}
 
 RUN yarn global add turbo
 COPY . .
-RUN turbo prune --scope=@growi/app --docker
+RUN turbo prune @growi/app --docker
 
 
 ##

+ 1 - 1
apps/app/next.config.js

@@ -135,7 +135,7 @@ module.exports = async(phase, { defaultConfig }) => {
   }
 
   const withBundleAnalyzer = require('@next/bundle-analyzer')({
-    enabled: phase === PHASE_PRODUCTION_BUILD || process.env.ANALYZE === 'true',
+    enabled: phase === PHASE_PRODUCTION_BUILD && process.env.ANALYZE === 'true',
   });
 
   return withBundleAnalyzer(withSuperjson()(nextConfig));

+ 9 - 0
apps/app/nodemon.json

@@ -0,0 +1,9 @@
+{
+  "ext": "js,ts,json",
+  "ignore": [
+    ".next",
+    "public/static",
+    "package.json",
+    "playwright"
+  ]
+}

+ 6 - 7
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.0.9",
+  "version": "7.0.10-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -17,7 +17,7 @@
     "styles-prebuilt": "vite build -c vite.styles-prebuilt.config.ts",
     "migrate": "node -r dotenv-flow/config node_modules/.bin/migrate-mongo up -f config/migrate-mongo-config.js",
     "//// for development": "",
-    "dev": "yarn cross-env NODE_ENV=development yarn ts-node-dev --inspect --transpile-only src/server/app.ts",
+    "dev": "yarn cross-env NODE_ENV=development nodemon --exec yarn ts-node --inspect src/server/app.ts",
     "dev:styles-prebuilt": "yarn styles-prebuilt --mode dev",
     "dev:migrate-mongo": "yarn cross-env NODE_ENV=development yarn ts-node node_modules/.bin/migrate-mongo",
     "dev:migrate": "yarn dev:migrate:status > tmp/cache/migration-status.out && yarn dev:migrate:up",
@@ -48,8 +48,7 @@
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
-    "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config",
-    "ts-node-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config",
+    "ts-node": "node -r ts-node/register/transpile-only -r tsconfig-paths/register -r dotenv-flow/config",
     "version": "yarn version --no-git-tag-version --preid=RC"
   },
   "// comments for dependencies": {
@@ -229,8 +228,8 @@
     "@handsontable/react": "=2.1.0",
     "@next/bundle-analyzer": "^14.1.3",
     "@popperjs/core": "^2.11.8",
-    "@swc-node/jest": "^1.6.2",
-    "@swc/jest": "^0.2.24",
+    "@swc-node/jest": "^1.8.1",
+    "@swc/jest": "^0.2.36",
     "@testing-library/react": "^14.1.2",
     "@testing-library/user-event": "^14.5.2",
     "@types/express": "^4.17.11",
@@ -241,7 +240,7 @@
     "@types/throttle-debounce": "^5.0.1",
     "@types/unzip-stream": "^0.3.4",
     "@types/url-join": "^4.0.2",
-    "@vitejs/plugin-react": "^4.2.1",
+    "@vitejs/plugin-react": "^4.3.0",
     "@vitest/coverage-v8": "^0.34.6",
     "babel-loader": "^8.2.5",
     "bootstrap": "=5.3.2",

+ 110 - 0
apps/app/playwright.config.ts

@@ -0,0 +1,110 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { defineConfig, devices } from '@playwright/test';
+
+const authFile = path.resolve(__dirname, './playwright/.auth/admin.json');
+
+/**
+ * Read environment variables from file.
+ * https://github.com/motdotla/dotenv
+ */
+// require('dotenv').config();
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+  timeout: 7 * 1000,
+
+  testDir: './playwright',
+  outputDir: './playwright/output',
+  /* Run tests in files in parallel */
+  fullyParallel: true,
+  /* Fail the build on CI if you accidentally left test.only in the source code. */
+  forbidOnly: !!process.env.CI,
+  /* Retry on CI only */
+  retries: process.env.CI ? 2 : 0,
+  /* Opt out of parallel tests on CI. */
+  workers: process.env.CI ? 1 : undefined,
+  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+  reporter: process.env.CI ? 'github' : 'list',
+
+  webServer: {
+    command: 'yarn server',
+    url: 'http://localhost:3000',
+    reuseExistingServer: !process.env.CI,
+    stdout: 'ignore',
+    stderr: 'pipe',
+  },
+
+  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+  use: {
+    /* Base URL to use in actions like `await page.goto('/')`. */
+    baseURL: 'http://localhost:3000',
+
+    /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+    trace: 'on-first-retry',
+
+    viewport: { width: 1400, height: 1024 },
+
+    // Use prepared auth state.
+    storageState: fs.existsSync(authFile) ? authFile : undefined,
+  },
+
+  /* Configure projects for major browsers */
+  projects: [
+    // Setup project
+    { name: 'setup', testMatch: /.*\.setup\.ts/, testIgnore: /auth\.setup\.ts/ },
+    { name: 'auth', testMatch: /auth\.setup\.ts/ },
+
+    {
+      name: 'chromium/installer',
+      use: { ...devices['Desktop Chrome'] },
+      testMatch: /10-installer\/.*\.spec\.ts/,
+      dependencies: ['setup'],
+    },
+
+    {
+      name: 'chromium',
+      use: { ...devices['Desktop Chrome'] },
+      testIgnore: /10-installer\/.*\.spec\.ts/,
+      dependencies: ['setup', 'auth'],
+    },
+
+    {
+      name: 'firefox',
+      use: { ...devices['Desktop Firefox'] },
+      testIgnore: /10-installer\/.*\.spec\.ts/,
+      dependencies: ['setup', 'auth'],
+    },
+
+    {
+      name: 'webkit',
+      use: { ...devices['Desktop Safari'] },
+      testIgnore: /10-installer\/.*\.spec\.ts/,
+      dependencies: ['setup', 'auth'],
+    },
+
+    /* Test against mobile viewports. */
+    // {
+    //   name: 'Mobile Chrome',
+    //   use: { ...devices['Pixel 5'] },
+    // },
+    // {
+    //   name: 'Mobile Safari',
+    //   use: { ...devices['iPhone 12'] },
+    // },
+
+    /* Test against branded browsers. */
+    // {
+    //   name: 'Microsoft Edge',
+    //   use: { ...devices['Desktop Edge'], channel: 'msedge' },
+    // },
+    // {
+    //   name: 'Google Chrome',
+    //   use: { ...devices['Desktop Chrome'], channel: 'chrome' },
+    // },
+  ],
+
+});

+ 0 - 0
apps/app/playwright/.auth/.gitkeep


+ 16 - 0
apps/app/playwright/.eslintrc.mjs

@@ -0,0 +1,16 @@
+import playwright from 'eslint-plugin-playwright';
+
+// eslint-disable-next-line import/no-anonymous-default-export
+export default [
+  {
+    ...playwright.configs['flat/recommended'],
+    files: ['./**'],
+  },
+  {
+    files: ['./**'],
+    rules: {
+      // Customize Playwright rules
+      // ...
+    },
+  },
+];

+ 2 - 0
apps/app/playwright/.gitignore

@@ -0,0 +1,2 @@
+.auth
+output

+ 47 - 0
apps/app/playwright/10-installer/install.spec.ts

@@ -0,0 +1,47 @@
+import { test, expect } from '@playwright/test';
+
+test('Installer', async({ page }) => {
+  await page.goto('/');
+  await page.waitForURL('/installer');
+
+  // show installer form
+  await expect(page.getByTestId('installerForm')).toBeVisible();
+
+  // choose Japanese
+  await page.getByTestId('dropdownLanguage').click();
+  await page.getByTestId('dropdownLanguageMenu-ja_JP').click();
+  await expect(page.getByRole('textbox', { name: 'ユーザーID' })).toBeVisible();
+  await expect(page.getByRole('textbox', { name: 'ユーザーID' })).toHaveAttribute('placeholder', 'ユーザーID');
+
+  // choose Chinese
+  await page.getByTestId('dropdownLanguage').click();
+  await page.getByTestId('dropdownLanguageMenu-zh_CN').click();
+  await expect(page.getByRole('textbox', { name: '用户ID' })).toBeVisible();
+  await expect(page.getByRole('textbox', { name: '用户ID' })).toHaveAttribute('placeholder', '用户ID');
+  // // choose English
+  await page.getByTestId('dropdownLanguage').click();
+  await page.getByTestId('dropdownLanguageMenu-en_US').click();
+  await expect(page.getByRole('textbox', { name: 'User ID' })).toBeVisible();
+  await expect(page.getByRole('textbox', { name: 'User ID' })).toHaveAttribute('placeholder', 'User ID');
+
+  await page.getByRole('textbox', { name: 'User ID' }).focus();
+
+  // fill form
+  await page.getByLabel('User ID').fill('admin');
+  await page.getByLabel('User ID').press('Tab');
+  await expect(page.getByRole('textbox', { name: 'Name' })).toBeFocused();
+
+  await page.getByLabel('Name').fill('Admin');
+  await page.getByLabel('Name').press('Tab');
+  await expect(page.getByRole('textbox', { name: 'Email' })).toBeFocused();
+
+  await page.getByLabel('Email').fill('admin@example.com');
+  await page.getByLabel('Email').press('Tab');
+  await expect(page.getByRole('textbox', { name: 'Password' })).toBeFocused();
+
+  await page.getByLabel('Password').fill('adminadmin');
+  await page.getByLabel('Password').press('Enter');
+
+  await page.waitForURL('/', { timeout: 20000 });
+  await expect(page).toHaveTitle(/\/ - GROWI/);
+});

+ 22 - 0
apps/app/playwright/20-basic-features/access-to-page.spec.ts

@@ -0,0 +1,22 @@
+import { test, expect } from '@playwright/test';
+
+test('has title', async({ page }) => {
+  await page.goto('/Sandbox');
+
+  // Expect a title "to contain" a substring.
+  await expect(page).toHaveTitle(/Sandbox/);
+});
+
+test('get h1', async({ page }) => {
+  await page.goto('/Sandbox');
+
+  // Expects page to have a heading with the name of Installation.
+  await expect(page.getByRole('heading').filter({ hasText: /\/Sandbox/ })).toBeVisible();
+});
+
+test('Access to /me page', async({ page }) => {
+  await page.goto('/me');
+
+  // Expect the UserSettgins-specific elements to be present when accessing /me (UserSettgins)
+  await expect(page.getByTestId('grw-user-settings')).toBeVisible();
+});

+ 27 - 0
apps/app/playwright/20-basic-features/create-page-button.spec.ts

@@ -0,0 +1,27 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Create page button', () => {
+  test('click and autofocus to title text input', async({ page }) => {
+    await page.goto('/');
+
+    await page.getByTestId('grw-page-create-button').getByRole('button', { name: 'Create' }).click();
+
+    // should be focused
+    await expect(page.getByPlaceholder('Input page name')).toBeFocused();
+  });
+});
+
+test.describe('Create page button dropdown menu', () => {
+  test('open and create today page', async({ page }) => {
+    await page.goto('/');
+
+    // open dropdown menu
+    await page.getByTestId('grw-page-create-button').hover();
+    await expect(page.getByTestId('grw-page-create-button').getByLabel('Open create page menu')).toBeVisible();
+    await page.getByTestId('grw-page-create-button').getByLabel('Open create page menu').dispatchEvent('click'); // simulate the click
+    await page.getByRole('menuitem', { name: 'Create today page' }).click();
+
+    // should not be visible
+    await expect(page.getByPlaceholder('Input page name')).not.toBeVisible();
+  });
+});

+ 97 - 0
apps/app/playwright/40-admin/access-to-admin-page.spec.ts

@@ -0,0 +1,97 @@
+import { test, expect } from '@playwright/test';
+
+test('admin is successfully loaded', async({ page }) => {
+  await page.goto('/admin');
+
+  await expect(page.getByTestId('admin-home')).toBeVisible();
+  await expect(page.getByTestId('admin-system-information-table')).toBeVisible();
+});
+
+test('admin/app is successfully loaded', async({ page }) => {
+  await page.goto('/admin/app');
+
+  await expect(page.getByTestId('admin-app-settings')).toBeVisible();
+  // await expect(page.getByTestId('v5-page-migration')).toBeVisible();
+  await expect(page.locator('#cbFileUpload')).toBeChecked();
+  await expect(page.locator('#isQuestionnaireEnabled')).toBeChecked();
+  await expect(page.locator('#isAppSiteUrlHashed')).not.toBeChecked();
+});
+
+test('admin/security is successfully loaded', async({ page }) => {
+  await page.goto('/admin/security');
+
+  await expect(page.getByTestId('admin-security')).toBeVisible();
+  await expect(page.locator('#isShowRestrictedByOwner')).not.toBeChecked();
+  await expect(page.locator('#isShowRestrictedByGroup')).not.toBeChecked();
+});
+
+test('admin/markdown is successfully loaded', async({ page }) => {
+  await page.goto('/admin/markdown');
+
+  await expect(page.getByTestId('admin-markdown')).toBeVisible();
+  await expect(page.locator('#isEnabledLinebreaksInComments')).toBeChecked();
+});
+
+test('admin/customize is successfully loaded', async({ page }) => {
+  await page.goto('/admin/customize');
+
+  await expect(page.getByTestId('admin-customize')).toBeVisible();
+});
+
+test('admin/importer is successfully loaded', async({ page }) => {
+  await page.goto('/admin/importer');
+
+  await expect(page.getByTestId('admin-import-data')).toBeVisible();
+});
+
+test('admin/export is successfully loaded', async({ page }) => {
+  await page.goto('/admin/export');
+
+  await expect(page.getByTestId('admin-export-archive-data')).toBeVisible();
+});
+
+test('admin/notification is successfully loaded', async({ page }) => {
+  await page.goto('/admin/notification');
+
+  await expect(page.getByTestId('admin-notification')).toBeVisible();
+  // wait for retrieving slack integration status
+  await expect(page.getByTestId('slack-integration-list-item')).toBeVisible();
+});
+
+test('admin/slack-integration is successfully loaded', async({ page }) => {
+  await page.goto('/admin/slack-integration');
+
+  await expect(page.getByTestId('admin-slack-integration')).toBeVisible();
+  await expect(page.locator('img.bot-difficulty-icon')).toHaveCount(3);
+  await expect(page.locator('img.bot-difficulty-icon').first()).toBeVisible();
+});
+
+test('admin/slack-integration-legacy is successfully loaded', async({ page }) => {
+  await page.goto('/admin/slack-integration-legacy');
+
+  await expect(page.getByTestId('admin-slack-integration-legacy')).toBeVisible();
+});
+
+test('admin/users is successfully loaded', async({ page }) => {
+  await page.goto('/admin/users');
+
+  await expect(page.getByTestId('admin-users')).toBeVisible();
+  await expect(page.getByTestId('user-table-tr').first()).toBeVisible();
+});
+
+test('admin/user-groups is successfully loaded', async({ page }) => {
+  await page.goto('/admin/user-groups');
+
+  await expect(page.getByTestId('admin-user-groups')).toBeVisible();
+  await expect(page.getByTestId('grw-user-group-table').first()).toBeVisible();
+});
+
+test('admin/search is successfully loaded', async({ page }) => {
+  await page.goto('/admin/search');
+
+  await expect(page.getByTestId('admin-full-text-search')).toBeVisible();
+
+  // Only successful in the local environment.
+  // wait for connected
+  // await expect(page.getByTestId('connection-status-badge-connected')).toBeVisible();
+});

+ 24 - 0
apps/app/playwright/auth.setup.ts

@@ -0,0 +1,24 @@
+import path from 'node:path';
+
+import { test as setup, expect } from '@playwright/test';
+
+const authFile = path.resolve(__dirname, './.auth/admin.json');
+
+setup('Authenticate as the "admin" user', async({ page }) => {
+  // Perform authentication steps. Replace these actions with your own.
+  await page.goto('/admin');
+
+  const loginForm = await page.$('form#login-form');
+
+  if (loginForm != null) {
+    await page.getByLabel('Username or E-mail').fill('admin');
+    await page.getByLabel('Password').fill('adminadmin');
+    await page.locator('[type=submit]').filter({ hasText: 'Login' }).click();
+  }
+
+  await page.waitForURL('/admin');
+  await expect(page).toHaveTitle(/Wiki Management Homepage/);
+
+  // End of authentication steps.
+  await page.context().storageState({ path: authFile });
+});

+ 7 - 2
apps/app/src/client/services/create-page/use-create-page.tsx

@@ -8,7 +8,7 @@ import { toastWarning } from '~/client/util/toastr';
 import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
 import { useGrantedGroupsInheritanceSelectModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
-import { EditorMode, useEditorMode } from '~/stores/ui';
+import { EditorMode, useEditorMode, useIsUntitledPage } from '~/stores/ui';
 
 import { createPage } from './create-page';
 
@@ -51,6 +51,7 @@ export const useCreatePage: UseCreatePage = () => {
 
   const { data: currentPagePath } = useCurrentPagePath();
   const { mutate: mutateEditorMode } = useEditorMode();
+  const { mutate: mutateIsUntitledPage } = useIsUntitledPage();
   const { open: openGrantedGroupsInheritanceSelectModal, close: closeGrantedGroupsInheritanceSelectModal } = useGrantedGroupsInheritanceSelectModal();
 
   const [isCreating, setCreating] = useState(false);
@@ -107,6 +108,10 @@ export const useCreatePage: UseCreatePage = () => {
           mutateEditorMode(EditorMode.Editor);
         }
 
+        if (params.path == null) {
+          mutateIsUntitledPage(true);
+        }
+
         onCreated?.();
       }
       catch (err) {
@@ -129,7 +134,7 @@ export const useCreatePage: UseCreatePage = () => {
     }
 
     await _create();
-  }, [currentPagePath, mutateEditorMode, router, openGrantedGroupsInheritanceSelectModal, closeGrantedGroupsInheritanceSelectModal, t]);
+  }, [currentPagePath, mutateEditorMode, router, t, closeGrantedGroupsInheritanceSelectModal, mutateIsUntitledPage, openGrantedGroupsInheritanceSelectModal]);
 
   return {
     isCreating,

+ 6 - 6
apps/app/src/client/services/renderer/renderer.tsx

@@ -3,7 +3,6 @@ import assert from 'assert';
 import { isClient } from '@growi/core/dist/utils/browser-utils';
 import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client';
 import * as drawio from '@growi/remark-drawio';
-// eslint-disable-next-line import/extensions
 import * as lsxGrowiDirective from '@growi/remark-lsx/dist/client';
 import katex from 'rehype-katex';
 import sanitize from 'rehype-sanitize';
@@ -20,8 +19,8 @@ import { LightBox } from '~/components/ReactMarkdownComponents/LightBox';
 import { RichAttachment } from '~/components/ReactMarkdownComponents/RichAttachment';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import * as mermaid from '~/features/mermaid';
-import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import type { RendererOptions } from '~/interfaces/renderer-options';
+import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
@@ -36,6 +35,7 @@ import loggerFactory from '~/utils/logger';
 
 // import EasyGrid from './PreProcessor/EasyGrid';
 
+
 import '@growi/remark-lsx/dist/client/style.css';
 import '@growi/remark-attachment-refs/dist/client/style.css';
 
@@ -71,7 +71,7 @@ export const generateViewOptions = (
     remarkPlugins.push(breaks);
   }
 
-  if (config.xssOption === RehypeSanitizeOption.CUSTOM) {
+  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
     injectCustomSanitizeOption(config);
   }
 
@@ -132,7 +132,7 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   // add remark plugins
   // remarkPlugins.push();
 
-  if (config.xssOption === RehypeSanitizeOption.CUSTOM) {
+  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
     injectCustomSanitizeOption(config);
   }
 
@@ -184,7 +184,7 @@ export const generateSimpleViewOptions = (
     remarkPlugins.push(breaks);
   }
 
-  if (config.xssOption === RehypeSanitizeOption.CUSTOM) {
+  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
     injectCustomSanitizeOption(config);
   }
 
@@ -277,7 +277,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     remarkPlugins.push(breaks);
   }
 
-  if (config.xssOption === RehypeSanitizeOption.CUSTOM) {
+  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
     injectCustomSanitizeOption(config);
   }
 

+ 2 - 2
apps/app/src/components/Admin/App/AppSetting.jsx

@@ -40,7 +40,7 @@ const AppSetting = (props) => {
           <input
             className="form-control"
             type="text"
-            value={adminAppContainer.state.title || ''}
+            defaletValue={adminAppContainer.state.title || ''}
             onChange={(e) => {
               adminAppContainer.changeTitle(e.target.value);
             }}
@@ -60,7 +60,7 @@ const AppSetting = (props) => {
           <input
             className="form-control"
             type="text"
-            value={adminAppContainer.state.confidential || ''}
+            defaultValue={adminAppContainer.state.confidential || ''}
             onChange={(e) => {
               adminAppContainer.changeConfidential(e.target.value);
             }}

+ 1 - 1
apps/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx

@@ -149,7 +149,7 @@ const ElasticsearchManagement = () => {
 
   return (
     <>
-      <div data-testid="admin-full-text-search" className="row">
+      <div className="row">
         <div className="col-md-12">
           <StatusTable
             isInitialized={isInitialized}

+ 0 - 96
apps/app/src/components/Admin/MarkdownSetting/WhitelistInput.jsx

@@ -1,96 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import { defaultSchema as sanitizeDefaultSchema } from 'rehype-sanitize';
-
-import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class WhitelistInput extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.tagWhitelist = React.createRef();
-    this.attrWhitelist = React.createRef();
-
-    this.tags = sanitizeDefaultSchema.tagNames;
-    this.attrs = JSON.stringify(sanitizeDefaultSchema.attributes);
-
-    this.onClickRecommendTagButton = this.onClickRecommendTagButton.bind(this);
-    this.onClickRecommendAttrButton = this.onClickRecommendAttrButton.bind(this);
-  }
-
-  onClickRecommendTagButton() {
-    this.tagWhitelist.current.value = this.tags;
-    this.props.adminMarkDownContainer.setState({ tagWhitelist: this.tags });
-  }
-
-  onClickRecommendAttrButton() {
-    this.attrWhitelist.current.value = this.attrs;
-    this.props.adminMarkDownContainer.setState({ attrWhitelist: this.attrs });
-  }
-
-  render() {
-    const { t, adminMarkDownContainer } = this.props;
-
-    return (
-      <>
-        <div className="mt-4">
-          <div className="d-flex justify-content-between">
-            {t('markdown_settings.xss_options.tag_names')}
-            <p id="btn-import-tags" className="btn btn-sm btn-primary" onClick={this.onClickRecommendTagButton}>
-              {t('markdown_settings.xss_options.import_recommended', { target: 'Tags' })}
-            </p>
-          </div>
-          <textarea
-            className="form-control xss-list"
-            name="recommendedTags"
-            rows="6"
-            cols="40"
-            ref={this.tagWhitelist}
-            defaultValue={adminMarkDownContainer.state.tagWhitelist}
-            onChange={(e) => { adminMarkDownContainer.setState({ tagWhitelist: e.target.value }) }}
-          />
-        </div>
-        <div className="mt-4">
-          <div className="d-flex justify-content-between">
-            {t('markdown_settings.xss_options.tag_attributes')}
-            <p id="btn-import-tags" className="btn btn-sm btn-primary" onClick={this.onClickRecommendAttrButton}>
-              {t('markdown_settings.xss_options.import_recommended', { target: 'Attrs' })}
-            </p>
-          </div>
-          <textarea
-            className="form-control xss-list"
-            name="recommendedAttrs"
-            rows="6"
-            cols="40"
-            ref={this.attrWhitelist}
-            defaultValue={adminMarkDownContainer.state.attrWhitelist}
-            onChange={(e) => { adminMarkDownContainer.setState({ attrWhitelist: e.target.value }) }}
-          />
-        </div>
-      </>
-    );
-  }
-
-}
-
-
-WhitelistInput.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer).isRequired,
-
-};
-
-const PresentationFormWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-
-  return <WhitelistInput t={t} {...props} />;
-};
-
-const WhitelistWrapper = withUnstatedContainers(PresentationFormWrapperFC, [AdminMarkDownContainer]);
-
-export default WhitelistWrapper;

+ 79 - 0
apps/app/src/components/Admin/MarkdownSetting/WhitelistInput.tsx

@@ -0,0 +1,79 @@
+import { useCallback, useRef } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import type AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
+import { tagNames as recommendedTagNames, attributes as recommendedAttributes } from '~/services/renderer/recommended-whitelist';
+
+type Props ={
+  adminMarkDownContainer: AdminMarkDownContainer
+}
+
+export const WhitelistInput = (props: Props): JSX.Element => {
+
+  const { t } = useTranslation('admin');
+  const { adminMarkDownContainer } = props;
+
+  const tagNamesRef = useRef<HTMLTextAreaElement>(null);
+  const attrsRef = useRef<HTMLTextAreaElement>(null);
+
+  const clickRecommendTagButtonHandler = useCallback(() => {
+    if (tagNamesRef.current == null) {
+      return;
+    }
+
+    const tagWhitelist = recommendedTagNames.join(',');
+    tagNamesRef.current.value = tagWhitelist;
+    adminMarkDownContainer.setState({ tagWhitelist });
+  }, [adminMarkDownContainer]);
+
+  const clickRecommendAttrButtonHandler = useCallback(() => {
+    if (attrsRef.current == null) {
+      return;
+    }
+
+    const attrWhitelist = JSON.stringify(recommendedAttributes);
+    attrsRef.current.value = attrWhitelist;
+    adminMarkDownContainer.setState({ attrWhitelist });
+  }, [adminMarkDownContainer]);
+
+  return (
+    <>
+      <div className="mt-4">
+        <div className="d-flex justify-content-between">
+          {t('markdown_settings.xss_options.tag_names')}
+          <p id="btn-import-tags" className="btn btn-sm btn-primary" onClick={clickRecommendTagButtonHandler}>
+            {t('markdown_settings.xss_options.import_recommended', { target: 'Tags' })}
+          </p>
+        </div>
+        <textarea
+          ref={tagNamesRef}
+          className="form-control xss-list"
+          name="recommendedTags"
+          rows={6}
+          cols={40}
+          defaultValue={adminMarkDownContainer.state.tagWhitelist}
+          onChange={(e) => { adminMarkDownContainer.setState({ tagWhitelist: e.target.value }) }}
+        />
+      </div>
+      <div className="mt-4">
+        <div className="d-flex justify-content-between">
+          {t('markdown_settings.xss_options.tag_attributes')}
+          <p id="btn-import-tags" className="btn btn-sm btn-primary" onClick={clickRecommendAttrButtonHandler}>
+            {t('markdown_settings.xss_options.import_recommended', { target: 'Attrs' })}
+          </p>
+        </div>
+        <textarea
+          ref={attrsRef}
+          className="form-control xss-list"
+          name="recommendedAttrs"
+          rows={6}
+          cols={40}
+          defaultValue={adminMarkDownContainer.state.attrWhitelist}
+          onChange={(e) => { adminMarkDownContainer.setState({ attrWhitelist: e.target.value }) }}
+        />
+      </div>
+    </>
+  );
+
+};

+ 10 - 10
apps/app/src/components/Admin/MarkdownSetting/XssForm.jsx

@@ -2,17 +2,17 @@ import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
-import { defaultSchema as sanitizeDefaultSchema } from 'rehype-sanitize';
 
 import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { RehypeSanitizeOption } from '~/interfaces/rehype';
+import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
+import { tagNames as recommendedTagNames, attributes as recommendedAttributes } from '~/services/renderer/recommended-whitelist';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
-import WhitelistInput from './WhitelistInput';
+import { WhitelistInput } from './WhitelistInput';
 
 const logger = loggerFactory('growi:importer');
 
@@ -41,8 +41,8 @@ class XssForm extends React.Component {
     const { t, adminMarkDownContainer } = this.props;
     const { xssOption } = adminMarkDownContainer.state;
 
-    const rehypeRecommendedTags = sanitizeDefaultSchema.tagNames;
-    const rehypeRecommendedAttributes = JSON.stringify(sanitizeDefaultSchema.attributes);
+    const rehypeRecommendedTags = recommendedTagNames.join(',');
+    const rehypeRecommendedAttributes = JSON.stringify(recommendedAttributes);
 
     return (
       <div className="col-12 mt-3">
@@ -55,8 +55,8 @@ class XssForm extends React.Component {
                 className="form-check-input"
                 id="xssOption1"
                 name="XssOption"
-                checked={xssOption === RehypeSanitizeOption.RECOMMENDED}
-                onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeOption.RECOMMENDED }) }}
+                checked={xssOption === RehypeSanitizeType.RECOMMENDED}
+                onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeType.RECOMMENDED }) }}
               />
               <label className="form-label form-check-label w-100" htmlFor="xssOption1">
                 <p className="fw-bold">{t('markdown_settings.xss_options.recommended_setting')}</p>
@@ -97,12 +97,12 @@ class XssForm extends React.Component {
                 className="form-check-input"
                 id="xssOption2"
                 name="XssOption"
-                checked={xssOption === RehypeSanitizeOption.CUSTOM}
-                onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeOption.CUSTOM }) }}
+                checked={xssOption === RehypeSanitizeType.CUSTOM}
+                onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeType.CUSTOM }) }}
               />
               <label className="form-label form-check-label w-100" htmlFor="xssOption2">
                 <p className="fw-bold">{t('markdown_settings.xss_options.custom_whitelist')}</p>
-                <WhitelistInput customizable />
+                <WhitelistInput adminMarkDownContainer={adminMarkDownContainer} />
               </label>
             </div>
           </div>

+ 4 - 6
apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useState, useCallback, useEffect, useMemo,
+  useState, useCallback, useEffect,
 } from 'react';
 
 import {
@@ -18,7 +18,6 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import type { PageActionOnGroupDelete, SearchType } from '~/interfaces/user-group';
 import { SearchTypes } from '~/interfaces/user-group';
-import Xss from '~/services/xss';
 import { useIsAclEnabled } from '~/stores/context';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 import { useSWRxUserGroupPages, useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups } from '~/stores/user-group';
@@ -54,7 +53,6 @@ type Props = {
 const UserGroupDetailPage = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const router = useRouter();
-  const xss = useMemo(() => new Xss(), []);
   const { userGroupId: currentUserGroupId, isExternalGroup } = props;
 
   const { data: currentUserGroup } = useUserGroup(currentUserGroupId, isExternalGroup);
@@ -221,13 +219,13 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   const removeUserByUsername = useCallback(async(username: string) => {
     try {
       await apiv3Delete(`/user-groups/${currentUserGroupId}/users/${username}`);
-      toastSuccess(`Removed "${xss.process(username)}" from "${xss.process(currentUserGroup?.name)}"`);
+      toastSuccess(`Removed "${username}" from "${currentUserGroup?.name}"`);
       mutateUserGroupRelationList();
     }
     catch (err) {
-      toastError(new Error(`Unable to remove "${xss.process(username)}" from "${xss.process(currentUserGroup?.name)}"`));
+      toastError(new Error(`Unable to remove "${username}" from "${currentUserGroup?.name}"`));
     }
-  }, [currentUserGroup?.name, currentUserGroupId, mutateUserGroupRelationList, xss]);
+  }, [currentUserGroup?.name, currentUserGroupId, mutateUserGroupRelationList]);
 
   const showUpdateModal = useCallback((group: IUserGroupHasId) => {
     setUpdateModalShown(true);

+ 3 - 7
apps/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.tsx

@@ -1,5 +1,5 @@
 import type { FC, KeyboardEvent } from 'react';
-import React, { useState, useRef } from 'react';
+import React, { useState } from 'react';
 
 import type { IUserGroupHasId, IUserHasId } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
@@ -8,7 +8,6 @@ import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import type { SearchType } from '~/interfaces/user-group';
-import Xss from '~/services/xss';
 
 type Props = {
   userGroup: IUserGroupHasId,
@@ -25,25 +24,22 @@ export const UserGroupUserFormByInput: FC<Props> = (props) => {
   } = props;
 
   const { t } = useTranslation();
-  const typeaheadRef = useRef(null);
   const [inputUser, setInputUser] = useState<IUserHasId[]>([]);
   const [applicableUsers, setApplicableUsers] = useState<IUserHasId[]>([]);
   const [isLoading, setIsLoading] = useState(false);
   const [isSearchError, setIsSearchError] = useState(false);
 
-  const xss = new Xss();
-
   const addUserBySubmit = async() => {
     if (inputUser.length === 0) { return }
     const userName = inputUser[0].username;
 
     try {
       await onClickAddUserBtn(userName);
-      toastSuccess(`Added "${xss.process(userName)}" to "${xss.process(userGroup.name)}"`);
+      toastSuccess(`Added "${userName}" to "${userGroup.name}"`);
       setInputUser([]);
     }
     catch (err) {
-      toastError(new Error(`Unable to add "${xss.process(userName)}" to "${xss.process(userGroup.name)}"`));
+      toastError(new Error(`Unable to add "${userName}" to "${userGroup.name}"`));
     }
   };
 

+ 1 - 1
apps/app/src/components/Common/SubmittableInput/AutosizeSubmittableInput.tsx

@@ -25,6 +25,6 @@ export const AutosizeSubmittableInput = (props: SubmittableInputProps<AutosizeIn
   const submittableProps = useSubmittable(props);
 
   return (
-    <AutosizeInput {...submittableProps} data-testid="autosize-submittable-input" />
+    <AutosizeInput {...submittableProps} type="text" data-testid="autosize-submittable-input" />
   );
 };

+ 9 - 1
apps/app/src/components/Common/SubmittableInput/use-submittable.ts

@@ -18,6 +18,7 @@ export const useSubmittable = (props: SubmittableInputProps): Partial<React.Inpu
   } = props;
 
   const [inputText, setInputText] = useState(value ?? '');
+  const [lastSubmittedInputText, setLastSubmittedInputText] = useState<string|undefined>(value ?? '');
   const [isComposing, setComposing] = useState(false);
 
   const changeHandler = useCallback(async(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -34,6 +35,7 @@ export const useSubmittable = (props: SubmittableInputProps): Partial<React.Inpu
         if (isComposing) {
           return;
         }
+        setLastSubmittedInputText(inputText);
         onSubmit?.(inputText.trim());
         break;
       case 'Escape':
@@ -46,10 +48,16 @@ export const useSubmittable = (props: SubmittableInputProps): Partial<React.Inpu
   }, [inputText, isComposing, onCancel, onSubmit]);
 
   const blurHandler = useCallback((e) => {
+    // suppress continuous calls to submit by blur event
+    if (lastSubmittedInputText === inputText) {
+      return;
+    }
+
     // submit on blur
+    setLastSubmittedInputText(inputText);
     onSubmit?.(inputText.trim());
     onBlur?.(e);
-  }, [inputText, onSubmit, onBlur]);
+  }, [inputText, lastSubmittedInputText, onSubmit, onBlur]);
 
   const compositionStartHandler = useCallback((e: CompositionEvent<HTMLInputElement>) => {
     setComposing(true);

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

@@ -65,7 +65,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
     catch (err) {
       toastError(t('toaster.create_failed', { target: path }));
     }
-  }, [createTemplate, path, t]);
+  }, [createTemplate, onClose, path, t]);
 
   const parentPath = pathUtils.addTrailingSlash(path);
 

+ 18 - 19
apps/app/src/components/InstallerForm.tsx

@@ -170,11 +170,11 @@ const InstallerForm = memo((): JSX.Element => {
           </div>
 
           <div className={`input-group mb-3${hasErrorClass}`}>
-            <span className="p-2 text-white opacity-75">
-              <span className="material-symbols-outlined">person</span>
-            </span>
+            <label className="p-2 text-white opacity-75" aria-label={t('User ID')} htmlFor="tiUsername">
+              <span className="material-symbols-outlined" aria-hidden>person</span>
+            </label>
             <input
-              data-testid="tiUsername"
+              id="tiUsername"
               type="text"
               className="form-control rounded"
               placeholder={t('User ID')}
@@ -186,11 +186,11 @@ const InstallerForm = memo((): JSX.Element => {
           <p className="form-text">{ unavailableUserId }</p>
 
           <div className="input-group mb-3">
-            <span className="p-2 text-white opacity-75">
-              <span className="material-symbols-outlined">sell</span>
-            </span>
+            <label className="p-2 text-white opacity-75" aria-label={t('Name')} htmlFor="tiName">
+              <span className="material-symbols-outlined" aria-hidden>sell</span>
+            </label>
             <input
-              data-testid="tiName"
+              id="tiName"
               type="text"
               className="form-control rounded"
               placeholder={t('Name')}
@@ -200,11 +200,11 @@ const InstallerForm = memo((): JSX.Element => {
           </div>
 
           <div className="input-group mb-3">
-            <span className="p-2 text-white opacity-75">
-              <span className="material-symbols-outlined">mail</span>
-            </span>
+            <label className="p-2 text-white opacity-75" aria-label={t('Email')} htmlFor="tiEmail">
+              <span className="material-symbols-outlined" aria-hidden>mail</span>
+            </label>
             <input
-              data-testid="tiEmail"
+              id="tiEmail"
               type="email"
               className="form-control rounded"
               placeholder={t('Email')}
@@ -214,11 +214,11 @@ const InstallerForm = memo((): JSX.Element => {
           </div>
 
           <div className="input-group mb-3">
-            <span className="p-2 text-white opacity-75">
-              <span className="material-symbols-outlined">lock</span>
-            </span>
+            <label className="p-2 text-white opacity-75" aria-label={t('Password')} htmlFor="tiPassword">
+              <span className="material-symbols-outlined" aria-hidden>lock</span>
+            </label>
             <input
-              data-testid="tiPassword"
+              id="tiPassword"
               type="password"
               className="form-control rounded"
               placeholder={t('Password')}
@@ -229,19 +229,18 @@ const InstallerForm = memo((): JSX.Element => {
 
           <div className="input-group mt-4 justify-content-center">
             <button
-              data-testid="btnSubmit"
               type="submit"
               className="btn btn-secondary btn-register col-6 d-flex"
               disabled={isLoading}
             >
-              <span>
+              <span aria-hidden>
                 {isLoading ? (
                   <LoadingSpinner />
                 ) : (
                   <span className="material-symbols-outlined">person_add</span>
                 )}
               </span>
-              <span className="flex-grow-1">{ t('Create') }</span>
+              <label className="flex-grow-1">{ t('Create') }</label>
             </button>
           </div>
 

+ 9 - 7
apps/app/src/components/LoginForm/LoginForm.tsx

@@ -196,10 +196,11 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
         <form role="form" onSubmit={handleLoginWithLocalSubmit} id="login-form">
           <div className="input-group">
-            <span className="text-white opacity-75 d-flex align-items-center">
-              <span className="material-symbols-outlined">person</span>
-            </span>
+            <label className="text-white opacity-75 d-flex align-items-center" htmlFor="tiUsernameForLogin">
+              <span className="material-symbols-outlined" aria-label="Username or E-mail">person</span>
+            </label>
             <input
+              id="tiUsernameForLogin"
               type="text"
               className={`form-control rounded ms-2 ${isLdapStrategySetup ? 'ldap-space' : ''}`}
               data-testid="tiUsernameForLogin"
@@ -217,10 +218,11 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           </div>
 
           <div className="input-group">
-            <span className="text-white opacity-75 d-flex align-items-center">
-              <span className="material-symbols-outlined">lock</span>
-            </span>
+            <label className="text-white opacity-75 d-flex align-items-center" htmlFor="tiPasswordForLogin">
+              <span className="material-symbols-outlined" aria-label="Password">lock</span>
+            </label>
             <input
+              id="tiPasswordForLogin"
               type="password"
               className="form-control rounded ms-2"
               data-testid="tiPasswordForLogin"
@@ -241,7 +243,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 {isLoading ? (
                   <LoadingSpinner />
                 ) : (
-                  <span className="material-symbols-outlined">login</span>
+                  <span className="material-symbols-outlined" aria-label="Login">login</span>
                 )}
               </span>
               <span className="flex-grow-1">{t('Sign in')}</span>

+ 3 - 1
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -35,7 +35,6 @@ import {
   useIsDeviceLargerThanMd,
 } from '~/stores/ui';
 
-import { CreateTemplateModal } from '../CreateTemplateModal';
 import { NotAvailable } from '../NotAvailable';
 import { Skeleton } from '../Skeleton';
 
@@ -44,6 +43,9 @@ import { GroundGlassBar } from './GroundGlassBar';
 import styles from './GrowiContextualSubNavigation.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 
+
+const CreateTemplateModal = dynamic(() => import('../CreateTemplateModal').then(mod => mod.CreateTemplateModal), { ssr: false });
+
 const PageEditorModeManager = dynamic(
   () => import('./PageEditorModeManager').then(mod => mod.PageEditorModeManager),
   { ssr: false, loading: () => <Skeleton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skeleton']}`} /> },

+ 12 - 11
apps/app/src/components/PageControls/PageControls.tsx

@@ -1,3 +1,4 @@
+import type { MouseEventHandler } from 'react';
 import React, {
   memo, useCallback, useEffect, useMemo, useRef,
 } from 'react';
@@ -10,6 +11,7 @@ import {
 } from '@growi/core';
 import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
+import { DropdownItem } from 'reactstrap';
 
 import {
   toggleLike, toggleSubscribe,
@@ -65,7 +67,7 @@ const Tags = (props: TagsProps): JSX.Element => {
 };
 
 type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
-  onChange: () => void,
+  onClick: () => void,
   expandContentWidth?: boolean,
 }
 
@@ -73,24 +75,23 @@ const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    onChange, expandContentWidth,
+    onClick, expandContentWidth,
   } = props;
 
   return (
-    <div className="grw-page-control-dropdown-item dropdown-item">
-      <div className="form-check form-switch ms-1 flex-fill d-flex">
+    <DropdownItem className="grw-page-control-dropdown-item dropdown-item" onClick={onClick} toggle={false}>
+      <div className="form-check form-switch ms-1">
         <input
-          id="wide-view-checkbox"
-          className="form-check-input"
+          className="form-check-input pe-none"
           type="checkbox"
-          defaultChecked={expandContentWidth}
-          onChange={onChange}
+          checked={expandContentWidth}
+          onChange={() => {}}
         />
-        <label className="form-check-label flex-grow-1 ms-2" htmlFor="wide-view-checkbox">
+        <label className="form-check-label pe-none">
           { t('wide_view') }
         </label>
       </div>
-    </div>
+    </DropdownItem>
   );
 };
 
@@ -249,7 +250,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     }
     const wideviewMenuItemRenderer = (props: WideViewMenuItemProps) => {
 
-      return <WideViewMenuItem {...props} onChange={switchContentWidthClickHandler} expandContentWidth={expandContentWidth} />;
+      return <WideViewMenuItem {...props} onClick={switchContentWidthClickHandler} expandContentWidth={expandContentWidth} />;
     };
     return wideviewMenuItemRenderer;
   }, [pageInfo, switchContentWidthClickHandler, expandContentWidth]);

+ 1 - 1
apps/app/src/components/PageEditor/EditorNavbar/EditingUserList.tsx

@@ -30,7 +30,7 @@ export const EditingUserList: FC<Props> = ({ userList }) => {
     <div className="d-flex flex-column justify-content-start justify-content-sm-end">
       <div className="d-flex justify-content-start justify-content-sm-end">
         {firstFourUsers.map(user => (
-          <div className="ms-1">
+          <div key={user._id} className="ms-1">
             <UserPicture
               user={user}
               noLink

+ 19 - 6
apps/app/src/components/PageEditor/OptionsSelector.tsx

@@ -175,16 +175,19 @@ IndentSizeSelector.displayName = 'IndentSizeSelector';
 
 
 type SwitchItemProps = {
-  onClick: () => void,
+  inputId: string,
+  onChange: () => void,
   checked: boolean,
   text: string,
 };
 const SwitchItem = memo((props: SwitchItemProps): JSX.Element => {
-  const { onClick, checked, text } = props;
+  const {
+    inputId, onChange, checked, text,
+  } = props;
   return (
     <FormGroup switch>
-      <Input type="switch" checked={checked} onClick={onClick} />
-      <label>{text}</label>
+      <Input id={inputId} type="switch" checked={checked} onChange={onChange} />
+      <label htmlFor={inputId}>{text}</label>
     </FormGroup>
 
   );
@@ -203,7 +206,12 @@ const ConfigurationSelector = memo((): JSX.Element => {
     const isActive = editorSettings.styleActiveLine;
 
     return (
-      <SwitchItem onClick={() => update({ styleActiveLine: !isActive })} checked={isActive} text={t('page_edit.Show active line')} />
+      <SwitchItem
+        inputId="switchActiveLine"
+        onChange={() => update({ styleActiveLine: !isActive })}
+        checked={isActive}
+        text={t('page_edit.Show active line')}
+      />
     );
   }, [editorSettings, update, t]);
 
@@ -215,7 +223,12 @@ const ConfigurationSelector = memo((): JSX.Element => {
     const isActive = editorSettings.autoFormatMarkdownTable;
 
     return (
-      <SwitchItem onClick={() => update({ autoFormatMarkdownTable: !isActive })} checked={isActive} text={t('page_edit.auto_format_table')} />
+      <SwitchItem
+        inputId="switchTableAutoFormatting"
+        onChange={() => update({ autoFormatMarkdownTable: !isActive })}
+        checked={isActive}
+        text={t('page_edit.auto_format_table')}
+      />
     );
   }, [editorSettings, t, update]);
 

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

@@ -43,7 +43,7 @@ import { mutatePageTree } from '~/stores/page-listing';
 import { usePreviewOptions } from '~/stores/renderer';
 import {
   EditorMode,
-  useEditorMode, useSelectedGrant,
+  useEditorMode, useIsUntitledPage, useSelectedGrant,
 } from '~/stores/ui';
 import { useEditingUsers } from '~/stores/use-editing-users';
 import { useNextThemes } from '~/stores/use-next-themes';
@@ -102,6 +102,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { mutate: mutateWaitingSaveProcessing } = useWaitingSaveProcessing();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+  const { data: isUntitledPage } = useIsUntitledPage();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: defaultIndentSize } = useDefaultIndentSize();
@@ -278,10 +279,10 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   // set handler to focus
   useLayoutEffect(() => {
-    if (editorMode === EditorMode.Editor) {
+    if (editorMode === EditorMode.Editor && isUntitledPage === false) {
       codeMirrorEditor?.focus();
     }
-  }, [codeMirrorEditor, currentPage, editorMode]);
+  }, [codeMirrorEditor, editorMode, isUntitledPage]);
 
   // Detect indent size from contents (only when users are allowed to change it)
   useEffect(() => {

+ 12 - 8
apps/app/src/components/PageEditor/page-path-rename-utils.ts

@@ -7,15 +7,18 @@ import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { useSWRMUTxCurrentPage } from '~/stores/page';
 import { mutatePageTree, mutatePageList } from '~/stores/page-listing';
+import { useIsUntitledPage } from '~/stores/ui';
 
-type PagePathRenameHandler = (newPagePath: string, onRenameFinish?: () => void, onRenameFailure?: () => void) => Promise<void>
+
+type PagePathRenameHandler = (newPagePath: string, onRenameFinish?: () => void, onRenameFailure?: () => void, onRenamedSkipped?: () => void) => Promise<void>
 
 export const usePagePathRenameHandler = (
     currentPage?: IPagePopulatedToShowRevision | null,
 ): PagePathRenameHandler => {
 
-  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { t } = useTranslation();
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+  const { mutate: mutateIsUntitledPage } = useIsUntitledPage();
 
   const pagePathRenameHandler = useCallback(async(newPagePath, onRenameFinish, onRenameFailure) => {
 
@@ -23,20 +26,21 @@ export const usePagePathRenameHandler = (
       return;
     }
 
+    if (newPagePath === currentPage.path || newPagePath === '') {
+      onRenameFinish?.();
+      return;
+    }
+
     const onRenamed = (fromPath: string | undefined, toPath: string) => {
       mutatePageTree();
       mutatePageList();
+      mutateIsUntitledPage(false);
 
       if (currentPage.path === fromPath || currentPage.path === toPath) {
         mutateCurrentPage();
       }
     };
 
-    if (newPagePath === currentPage.path || newPagePath === '') {
-      onRenameFinish?.();
-      return;
-    }
-
     try {
       await apiv3Put('/pages/rename', {
         pageId: currentPage._id,
@@ -53,7 +57,7 @@ export const usePagePathRenameHandler = (
       onRenameFailure?.();
       toastError(err);
     }
-  }, [currentPage, mutateCurrentPage, t]);
+  }, [currentPage, mutateCurrentPage, mutateIsUntitledPage, t]);
 
   return pagePathRenameHandler;
 };

+ 11 - 16
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -1,5 +1,5 @@
 import type { ChangeEvent } from 'react';
-import { useState, useCallback } from 'react';
+import { useState, useCallback, useEffect } from 'react';
 
 import nodePath from 'path';
 
@@ -11,12 +11,12 @@ import { useTranslation } from 'next-i18next';
 
 import type { InputValidationResult } from '~/client/util/use-input-validator';
 import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
+import { EditorMode, useEditorMode, useIsUntitledPage } from '~/stores/ui';
 
 import { CopyDropdown } from '../Common/CopyDropdown';
 import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
 import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 
-
 import styles from './PageTitleHeader.module.scss';
 
 const moduleClass = styles['page-title-header'] ?? '';
@@ -49,12 +49,8 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
 
   const editedPageTitle = nodePath.basename(editedPagePath);
 
-  // TODO: https://redmine.weseek.co.jp/issues/142729
-  // https://regex101.com/r/Wg2Hh6/1
-  const untitledPageRegex = /^Untitled-\d+$/;
-
-  const isNewlyCreatedPage = (currentPage.wip && currentPage.latestRevision == null && untitledPageRegex.test(editedPageTitle)) ?? false;
-
+  const { data: editorMode } = useEditorMode();
+  const { data: isUntitledPage } = useIsUntitledPage();
 
   const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
     const newPageTitle = pathUtils.removeHeadingSlash(e.target.value);
@@ -95,13 +91,12 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
     setRenameInputShown(true);
   }, [currentPagePath, isMovable]);
 
-  // TODO: auto focus when create new page
-  // https://redmine.weseek.co.jp/issues/136128
-  // useEffect(() => {
-  //   if (isNewlyCreatedPage) {
-  //     setRenameInputShown(true);
-  //   }
-  // }, [currentPage._id, isNewlyCreatedPage]);
+  useEffect(() => {
+    setEditedPagePath(currentPagePath);
+    if (isUntitledPage && editorMode === EditorMode.Editor) {
+      setRenameInputShown(true);
+    }
+  }, [currentPage._id, currentPagePath, editorMode, isUntitledPage]);
 
   const isInvalid = validationResult != null;
 
@@ -116,7 +111,7 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
           <div className="position-relative">
             <div className="position-absolute w-100">
               <AutosizeSubmittableInput
-                value={isNewlyCreatedPage ? '' : editedPageTitle}
+                value={isUntitledPage ? '' : editedPageTitle}
                 inputClassName={`form-control fs-4 ${isInvalid ? 'is-invalid' : ''}`}
                 inputStyle={{ maxWidth: inputMaxWidth }}
                 placeholder={t('Input page name')}

+ 12 - 10
apps/app/src/components/PageSelectModal/PageSelectModal.tsx

@@ -1,8 +1,11 @@
 import type { FC } from 'react';
-import { Suspense, useState, useCallback } from 'react';
+import {
+  Suspense, useState, useCallback,
+} from 'react';
 
 import nodePath from 'path';
 
+import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter, Button,
@@ -11,7 +14,7 @@ import {
 import type { IPageForItem } from '~/interfaces/page';
 import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { usePageSelectModal } from '~/stores/modal';
-import { useCurrentPagePath, useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 
 import { ItemsTree } from '../ItemsTree';
 import ItemsTreeContentSkeleton from '../ItemsTree/ItemsTreeContentSkeleton';
@@ -19,7 +22,6 @@ import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 
 import { TreeItemForModal } from './TreeItemForModal';
 
-
 export const PageSelectModal: FC = () => {
   const {
     data: PageSelectModalData,
@@ -34,8 +36,6 @@ export const PageSelectModal: FC = () => {
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
-  const { data: currentPath } = useCurrentPagePath();
-  const { data: targetId } = useCurrentPageId();
   const { data: targetAndAncestorsData } = useTargetAndAncestors();
   const { data: currentPage } = useSWRxCurrentPage();
 
@@ -45,7 +45,7 @@ export const PageSelectModal: FC = () => {
     const parentPagePath = page.path;
 
     if (parentPagePath == null) {
-      return;
+      return <></>;
     }
 
     setClickedParentPagePath(parentPagePath);
@@ -67,12 +67,14 @@ export const PageSelectModal: FC = () => {
     closeModal();
   }, [clickedParentPagePath, closeModal, currentPage?.path, pagePathRenameHandler]);
 
-  const targetPathOrId = targetId || currentPath;
+  const parentPagePath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage?.path ?? ''));
+
+  const targetPathOrId = clickedParentPagePath || parentPagePath;
 
-  const path = currentPath || '/';
+  const targetPath = clickedParentPagePath || parentPagePath;
 
   if (isGuestUser == null) {
-    return null;
+    return <></>;
   }
 
   return (
@@ -89,7 +91,7 @@ export const PageSelectModal: FC = () => {
             CustomTreeItem={TreeItemForModal}
             isEnableActions={!isGuestUser}
             isReadOnlyUser={!!isReadOnlyUser}
-            targetPath={path}
+            targetPath={targetPath}
             targetPathOrId={targetPathOrId}
             targetAndAncestorsData={targetAndAncestorsData}
             onClickTreeItem={onClickTreeItem}

+ 20 - 0
apps/app/src/components/PageSelectModal/TreeItemForModal.module.scss

@@ -1,5 +1,25 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+
 .tree-item-for-modal :global {
   li {
     min-height: 36px;
   }
 }
+
+
+// == Colors
+@include bs.color-mode(light) {
+  .tree-item-for-modal :global {
+    .list-group-item-action {
+      --bs-list-group-active-bg: var(--grw-primary-200);
+    }
+  }
+}
+
+@include bs.color-mode(dark) {
+  .tree-item-for-modal :global {
+    .list-group-item-action {
+      --bs-list-group-active-bg: var(--grw-primary-700);
+    }
+  }
+}

+ 13 - 15
apps/app/src/components/PageSelectModal/TreeItemForModal.tsx

@@ -10,33 +10,31 @@ import styles from './TreeItemForModal.module.scss';
 const moduleClass = styles['tree-item-for-modal'];
 
 
-type PageTreeItemProps = TreeItemProps & {
+type TreeItemForModalProps = TreeItemProps & {
   key?: React.Key | null,
 };
 
-export const TreeItemForModal: FC<PageTreeItemProps> = (props) => {
+export const TreeItemForModal: FC<TreeItemForModalProps> = (props) => {
 
-  const { isOpen, onClick } = props;
+  const { itemNode, targetPathOrId } = props;
+  const { page } = itemNode;
 
   const { Input: NewPageInput, CreateButton: NewPageCreateButton } = useNewPageInput();
 
+  const isSelected = page._id === targetPathOrId || page.path === targetPathOrId;
+
+  const itemClassNames = [
+    isSelected ? 'active' : '',
+  ];
+
   return (
     <TreeItemLayout
-      key={props.key}
+      {...props}
       className={moduleClass}
-      targetPathOrId={props.targetPathOrId}
-      itemLevel={props.itemLevel}
-      itemNode={props.itemNode}
-      isOpen={isOpen}
-      isEnableActions={props.isEnableActions}
-      isReadOnlyUser={props.isReadOnlyUser}
-      onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
-      onClickDeleteMenuItem={props.onClickDeleteMenuItem}
-      onRenamed={props.onRenamed}
-      customHeadOfChildrenComponents={[NewPageInput]}
       itemClass={TreeItemForModal}
+      itemClassName={itemClassNames.join(' ')}
+      customHeadOfChildrenComponents={[NewPageInput]}
       customHoveredEndComponents={[NewPageCreateButton]}
-      onClick={onClick}
     />
   );
 };

+ 18 - 4
apps/app/src/components/ReactMarkdownComponents/Header.module.scss

@@ -3,18 +3,32 @@
     text-decoration: none;
   }
 
-  .revision-head-link,
-  .revision-head-edit-button {
+  .revision-head-link {
+    left: -1em;
+    width: 1em;
+    user-select: none;
+    opacity: 0;
+  }
+  .revision-head-edit-button{
     margin-left: 0.5em;
-    font-size: 0.6em;
+    font-size: 16px;
     user-select: none;
     opacity: 0;
+    .material-symbols-outlined{
+      vertical-align: middle;
+    }
   }
 }
 
 .revision-head:hover :global {
   .revision-head-link, .revision-head-edit-button {
-    opacity: 1 !important;
+    opacity: 0.5;
+  }
+  .revision-head-link:hover {
+    opacity: 1;
+  }
+  .revision-head-edit-button:hover {
+    opacity: 1;
   }
 }
 

+ 14 - 9
apps/app/src/components/ReactMarkdownComponents/Header.tsx

@@ -16,6 +16,7 @@ import styles from './Header.module.scss';
 
 
 const logger = loggerFactory('growi:components:Header');
+const moduleClass = styles['revision-head'] ?? '';
 
 declare global {
   // eslint-disable-next-line vars-on-top, no-var
@@ -113,14 +114,18 @@ export const Header = (props: HeaderProps): JSX.Element => {
   const showEditButton = !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null;
 
   return (
-    <CustomTag id={id} className={`${styles['revision-head']} ${isActive ? styles.blink : ''}`}>
-      {children}
-      <NextLink href={`#${id}`} className="revision-head-link">
-        <span className="material-symbols-outlined">link</span>
-      </NextLink>
-      {showEditButton && (
-        <EditLink line={node.position?.start.line} />
-      )}
-    </CustomTag>
+    <>
+      <CustomTag id={id} className={`position-relative ${moduleClass} ${isActive ? styles.blink : ''} `}>
+        <NextLink href={`#${id}`} className="d-none d-md-inline revision-head-link position-absolute">
+          #
+        </NextLink>
+
+        {children}
+
+        { showEditButton && (
+          <EditLink line={node.position?.start.line} />
+        ) }
+      </CustomTag>
+    </>
   );
 };

+ 8 - 8
apps/app/src/components/Script/DrawioViewerScript.tsx → apps/app/src/components/Script/DrawioViewerScript/DrawioViewerScript.tsx

@@ -3,15 +3,19 @@ import { useCallback } from 'react';
 import type { IGraphViewerGlobal } from '@growi/remark-drawio';
 import Head from 'next/head';
 
-import { useRendererConfig } from '~/stores/context';
+import { useViewerMinJsUrl } from './use-viewer-min-js-url';
 
 declare global {
   // eslint-disable-next-line vars-on-top, no-var
   var GraphViewer: IGraphViewerGlobal;
 }
 
-export const DrawioViewerScript = (): JSX.Element => {
-  const { data: rendererConfig } = useRendererConfig();
+type Props = {
+  drawioUri: string;
+}
+
+export const DrawioViewerScript = ({ drawioUri }: Props): JSX.Element => {
+  const viewerMinJsSrc = useViewerMinJsUrl(drawioUri);
 
   const loadedHandler = useCallback(() => {
     // disable useResizeSensor and checkVisibleState
@@ -32,16 +36,12 @@ export const DrawioViewerScript = (): JSX.Element => {
     GraphViewer.processElements();
   }, []);
 
-  if (rendererConfig == null) {
-    return <></>;
-  }
-
   return (
     <Head>
       <script
         type="text/javascript"
         async
-        src={(new URL('/js/viewer.min.js', rendererConfig.drawioUri)).toString()}
+        src={viewerMinJsSrc}
         onLoad={loadedHandler}
       />
     </Head>

+ 1 - 0
apps/app/src/components/Script/DrawioViewerScript/index.ts

@@ -0,0 +1 @@
+export * from './DrawioViewerScript';

+ 17 - 0
apps/app/src/components/Script/DrawioViewerScript/use-viewer-min-js-url.spec.ts

@@ -0,0 +1,17 @@
+import { useViewerMinJsUrl } from './use-viewer-min-js-url';
+
+describe('useViewerMinJsUrl', () => {
+  it.each`
+    drawioUri                                     | expected
+    ${'http://localhost:8080'}                    | ${'http://localhost:8080/js/viewer.min.js'}
+    ${'http://example.com'}                       | ${'http://example.com/js/viewer.min.js'}
+    ${'http://example.com/drawio'}                | ${'http://example.com/drawio/js/viewer.min.js'}
+    ${'http://example.com/?offline=1&https=0'}    | ${'http://example.com/js/viewer.min.js?offline=1&https=0'}
+  `('should return the expected URL "$expected" when drawioUri is "$drawioUrk"', ({ drawioUri, expected }: {drawioUri: string, expected: string}) => {
+    // Act
+    const url = useViewerMinJsUrl(drawioUri);
+
+    // Assert
+    expect(url).toBe(expected);
+  });
+});

+ 9 - 0
apps/app/src/components/Script/DrawioViewerScript/use-viewer-min-js-url.ts

@@ -0,0 +1,9 @@
+import urljoin from 'url-join';
+
+export const useViewerMinJsUrl = (drawioUri: string): string => {
+  // extract search from URL
+  const url = new URL(drawioUri);
+  const pathname = urljoin(url.pathname, '/js/viewer.min.js');
+
+  return `${url.origin}${pathname}${url.search}`;
+};

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

@@ -17,7 +17,7 @@ export const CreateButton = (props: Props): JSX.Element => {
       className={`${moduleClass} btn btn-primary ${props.className ?? ''}`}
     >
       <Hexagon />
-      <span className="icon material-symbols-outlined position-absolute">edit</span>
+      <span className="icon material-symbols-outlined position-absolute" aria-label="Create">edit</span>
     </button>
   );
 };

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

@@ -48,6 +48,7 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
           <DropdownItem divider />
           <li><span className="text-muted px-3">{t('create_page_dropdown.todays.desc')}</span></li>
           <DropdownItem
+            aria-label="Create today page"
             onClick={onClickCreateTodaysMemo}
           >
             {todaysPath}

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

@@ -14,9 +14,10 @@ export const DropendToggle = (): JSX.Element => {
       color="primary"
       className={`position-absolute z-1 ${moduleClass}`}
       aria-expanded={false}
+      aria-label="Open create page menu"
       data-testid="grw-page-create-button-dropend-toggle"
     >
-      <Hexagon />
+      <Hexagon className="pe-none" />
       <div className="hitarea position-absolute" />
       <span className="icon material-symbols-outlined position-absolute">chevron_right</span>
     </DropdownToggle>

+ 16 - 12
apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx

@@ -54,16 +54,15 @@ export const PageTreeHeader = memo(({ isWipPageShown, onWipPageShownChange }: He
         </button>
 
         <ul className="dropdown-menu">
-          <li className="dropdown-item">
-            <div className="form-check form-switch flex-fill d-flex">
+          <li className="dropdown-item" onClick={onWipPageShownChange}>
+            <div className="form-check form-switch">
               <input
-                id="show-wip-page-checkbox"
-                className="form-check-input"
+                className="form-check-input pe-none"
                 type="checkbox"
-                defaultChecked={isWipPageShown}
-                onChange={onWipPageShownChange}
+                checked={isWipPageShown}
+                onChange={() => {}}
               />
-              <label className="form-check-label flex-grow-1 ms-2" htmlFor="show-wip-page-checkbox">
+              <label className="form-check-label pe-none">
                 {t('sidebar_header.show_wip_page')}
               </label>
             </div>
@@ -111,19 +110,25 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
   const { data: sidebarScrollerRef } = useSidebarScrollerRef();
   const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
 
-  const rootElemRef = useRef(null);
+  const rootElemRef = useRef<HTMLDivElement>(null);
 
   // ***************************  Scroll on init ***************************
   const scrollOnInit = useCallback(() => {
-    const scrollTargetElement = document.getElementById('grw-pagetree-current-page-item');
+    const rootElement = rootElemRef.current;
+    const scrollElement = sidebarScrollerRef?.current;
 
-    if (sidebarScrollerRef?.current == null || scrollTargetElement == null) {
+    if (rootElement == null || scrollElement == null) {
+      return;
+    }
+
+    const scrollTargetElement = rootElement.querySelector<HTMLElement>('[aria-current]');
+
+    if (scrollTargetElement == null) {
       return;
     }
 
     logger.debug('scrollOnInit has invoked');
 
-    const scrollElement = sidebarScrollerRef.current;
 
     // NOTE: could not use scrollIntoView
     //  https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move
@@ -175,7 +180,6 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
     return null;
   }
 
-
   return (
     <div ref={rootElemRef} className="pt-4">
       <ItemsTree

+ 8 - 3
apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -26,6 +26,7 @@ import { CountBadgeForPageTreeItem } from './CountBadgeForPageTreeItem';
 import { CreatingNewPageSpinner } from './CreatingNewPageSpinner';
 import { usePageItemControl } from './use-page-item-control';
 
+
 import styles from './PageTreeItem.module.scss';
 
 const moduleClass = styles['page-tree-item'] ?? '';
@@ -56,7 +57,7 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
   const { t } = useTranslation();
 
   const {
-    itemNode, isOpen: _isOpen = false, onRenamed,
+    itemNode, targetPathOrId, isOpen: _isOpen = false, onRenamed,
   } = props;
 
   const { page } = itemNode;
@@ -166,7 +167,11 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
     drop(c);
   };
 
-  const itemClassName = `${isOver ? 'drag-over' : ''}`;
+  const isSelected = page._id === targetPathOrId || page.path === targetPathOrId;
+  const itemClassNames = [
+    isOver ? 'drag-over' : '',
+    page.path !== '/' && isSelected ? 'active' : '', // set 'active' except the root page
+  ];
 
   return (
     <TreeItemLayout
@@ -184,7 +189,7 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
       onRenamed={props.onRenamed}
       itemRef={itemRef}
       itemClass={PageTreeItem}
-      itemClassName={itemClassName}
+      itemClassName={itemClassNames.join(' ')}
       customEndComponents={[CountBadgeForPageTreeItem]}
       customHoveredEndComponents={[Control, NewPageCreateButton]}
       customHeadOfChildrenComponents={[NewPageInput, () => <CreatingNewPageSpinner show={isProcessingSubmission} />]}

+ 7 - 8
apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx

@@ -165,10 +165,10 @@ export const RecentChangesHeader = ({
     }
   }, [onSizeChange]);
 
-  const changeSizeHandler = useCallback((e) => {
-    onSizeChange(e.target.checked);
-    window.localStorage.setItem('isRecentChangesSidebarSmall', e.target.checked);
-  }, [onSizeChange]);
+  const changeSizeHandler = useCallback(() => {
+    onSizeChange(!isSmall);
+    window.localStorage.setItem('isRecentChangesSidebarSmall', String(isSmall));
+  }, [isSmall, onSizeChange]);
 
   // componentDidMount
   useEffect(() => {
@@ -196,12 +196,12 @@ export const RecentChangesHeader = ({
             <div className={`${styles['grw-recent-changes-resize-button']} form-check form-switch mb-0`}>
               <input
                 id="recentChangesResize"
-                className="form-check-input"
+                className="form-check-input pe-none"
                 type="checkbox"
                 checked={isSmall}
                 onChange={() => {}}
               />
-              <label className="form-label form-check-label text-muted mb-0" htmlFor="recentChangesResize">
+              <label className="form-check-label pe-none" aria-disabled="true">
                 {isSmall ? t('sidebar_header.size_s') : t('sidebar_header.size_l')}
               </label>
             </div>
@@ -214,9 +214,8 @@ export const RecentChangesHeader = ({
                 className="form-check-input"
                 type="checkbox"
                 checked={isWipPageShown}
-                onChange={() => {}}
               />
-              <label className="form-label form-check-label text-muted mb-0" htmlFor="wipPageVisibility">
+              <label className="form-check-label pe-none">
                 {t('sidebar_header.show_wip_page')}
               </label>
             </div>

+ 9 - 27
apps/app/src/components/TreeItem/TreeItemLayout.tsx

@@ -1,10 +1,8 @@
 import React, {
-  useCallback, useState, useEffect,
+  useCallback, useState, useEffect, useMemo,
   type FC, type RefObject, type RefCallback, type MouseEvent,
 } from 'react';
 
-import type { Nullable } from '@growi/core';
-
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 
@@ -18,24 +16,6 @@ import styles from './TreeItemLayout.module.scss';
 const moduleClass = styles['tree-item-layout'] ?? '';
 
 
-// Utility to mark target
-const markTarget = (children: ItemNode[], targetPathOrId?: Nullable<string>): void => {
-  if (targetPathOrId == null) {
-    return;
-  }
-
-  children.forEach((node) => {
-    if (node.page._id === targetPathOrId || node.page.path === targetPathOrId) {
-      node.page.isTarget = true;
-    }
-    else {
-      node.page.isTarget = false;
-    }
-    return node;
-  });
-};
-
-
 type TreeItemLayoutProps = TreeItemProps & {
   className?: string,
   itemRef?: RefObject<any> | RefCallback<any>,
@@ -98,7 +78,6 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
    */
   useEffect(() => {
     if (children.length > currentChildren.length) {
-      markTarget(children, targetPathOrId);
       setCurrentChildren(children);
     }
   }, [children, currentChildren.length, targetPathOrId]);
@@ -109,11 +88,14 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
   useEffect(() => {
     if (isOpen && data != null) {
       const newChildren = ItemNode.generateNodesFromPages(data.children);
-      markTarget(newChildren, targetPathOrId);
       setCurrentChildren(newChildren);
     }
   }, [data, isOpen, targetPathOrId]);
 
+  const isSelected = useMemo(() => {
+    return page._id === targetPathOrId || page.path === targetPathOrId;
+  }, [page, targetPathOrId]);
+
   const ItemClassFixed = itemClass ?? TreeItemLayout;
 
   const baseProps: Omit<TreeItemProps, 'itemLevel' | 'itemNode'> = {
@@ -155,11 +137,11 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
       <li
         ref={itemRef}
         role="button"
-        className={`list-group-item ${itemClassName}
-          border-0 py-0 ps-0 d-flex align-items-center rounded-1
-          ${page.isTarget ? 'active' : 'list-group-item-action'}`}
-        id={page.isTarget ? 'grw-pagetree-current-page-item' : `grw-pagetree-list-${page._id}`}
+        className={`list-group-item list-group-item-action ${itemClassName}
+          border-0 py-0 ps-0 d-flex align-items-center rounded-1`}
+        id={`grw-pagetree-list-${page._id}`}
         onClick={itemClickHandler}
+        aria-current={isSelected ? true : undefined}
       >
 
         <div className="btn-triangle-container d-flex justify-content-center">

+ 9 - 17
apps/app/src/features/growi-plugin/server/models/vo/github-url.ts

@@ -1,13 +1,14 @@
+
 import sanitize from 'sanitize-filename';
 
 // https://regex101.com/r/fK2rV3/1
 const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
-// https://regex101.com/r/CQjSuz/1
-const sanitizeBranchChars = new RegExp(/[^a-zA-Z0-9_.]+/g);
 
-// https://regex101.com/r/f4wj8q/1
+// https://regex101.com/r/CQjSuz/1
+const sanitizeSymbolsChars = new RegExp(/[^a-zA-Z0-9_.]+/g);
+// https://regex101.com/r/ARgXvb/1
 // GitHub will return a zip file with the v removed if the tag or branch name is "v + number"
-const checkVersionName = new RegExp(/^v[\d]/g);
+const sanitizeVersionChars = new RegExp(/^v[\d]/gi);
 
 export class GitHubUrl {
 
@@ -38,23 +39,14 @@ export class GitHubUrl {
   get archiveUrl(): string {
     const encodedBranchName = encodeURIComponent(this.branchName);
     const encodedTagName = encodeURIComponent(this.tagName);
-    if (encodedTagName !== '') {
-      const ghUrl = new URL(`/${this.organizationName}/${this.reposName}/archive/refs/tags/${encodedTagName}.zip`, 'https://github.com');
-      return ghUrl.toString();
-    }
-
-    const ghUrl = new URL(`/${this.organizationName}/${this.reposName}/archive/refs/heads/${encodedBranchName}.zip`, 'https://github.com');
+    const zipUrl = encodedTagName !== '' ? `tags/${encodedTagName}` : `heads/${encodedBranchName}`;
+    const ghUrl = new URL(`/${this.organizationName}/${this.reposName}/archive/refs/${zipUrl}.zip`, 'https://github.com');
     return ghUrl.toString();
-
   }
 
   get extractedArchiveDirName(): string {
-    if (this._tagName !== '') {
-      const tagName = this._tagName?.match(checkVersionName) ? this._tagName.replace('v', '') : this._tagName;
-      return tagName.replaceAll(sanitizeBranchChars, '-');
-    }
-    const branchName = this._branchName?.match(checkVersionName) ? this._branchName.replace('v', '') : this._branchName;
-    return branchName.replaceAll(sanitizeBranchChars, '-');
+    const name = this._tagName !== '' ? this._tagName : this._branchName;
+    return name.replace(sanitizeVersionChars, m => m.substring(1)).replaceAll(sanitizeSymbolsChars, '-');
   }
 
   constructor(url: string, branchName = 'main', tagName = '') {

+ 1 - 1
apps/app/src/interfaces/page.ts

@@ -10,7 +10,7 @@ export {
   isIPageInfoForEntity, isIPageInfoForOperation, isIPageInfoForListing,
 } from '@growi/core';
 
-export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean, processData?: IPageOperationProcessData}>;
+export type IPageForItem = Partial<IPageHasId & {processData?: IPageOperationProcessData}>;
 
 export const UserGroupPageGrantStatus = {
   isGranted: 'isGranted',

+ 0 - 6
apps/app/src/interfaces/rehype.ts

@@ -1,6 +0,0 @@
-export const RehypeSanitizeOption = {
-  RECOMMENDED: 'Recommended',
-  CUSTOM: 'Custom',
-} as const;
-
-export type RehypeSanitizeOption = typeof RehypeSanitizeOption[keyof typeof RehypeSanitizeOption];

+ 15 - 0
apps/app/src/interfaces/services/rehype-sanitize.ts

@@ -0,0 +1,15 @@
+import type { Attributes } from 'hast-util-sanitize/lib';
+
+export const RehypeSanitizeType = {
+  RECOMMENDED: 'Recommended',
+  CUSTOM: 'Custom',
+} as const;
+
+export type RehypeSanitizeType = typeof RehypeSanitizeType[keyof typeof RehypeSanitizeType];
+
+export type RehypeSanitizeConfiguration = {
+  isEnabledXssPrevention: boolean,
+  sanitizeType: RehypeSanitizeType,
+  customTagWhitelist?: Array<string> | null,
+  customAttrWhitelist?: Attributes | null,
+}

+ 2 - 2
apps/app/src/interfaces/services/renderer.ts

@@ -1,4 +1,4 @@
-import { XssOptionConfig } from '~/services/xss/xssOption';
+import type { RehypeSanitizeConfiguration } from './rehype-sanitize';
 
 export type RendererConfig = {
   isSharedPage?: boolean
@@ -11,4 +11,4 @@ export type RendererConfig = {
 
   drawioUri: string,
   plantumlUri: string,
-} & XssOptionConfig;
+} & RehypeSanitizeConfiguration;

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

@@ -371,7 +371,7 @@ Page.getLayout = function getLayout(page: React.ReactElement<Props>) {
   return (
     <>
       <GrowiPluginsActivator />
-      <DrawioViewerScript />
+      <DrawioViewerScript drawioUri={page.props.rendererConfig.drawioUri} />
 
       <Layout {...page.props}>
         {page}
@@ -566,9 +566,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-    attrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
-    tagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+    sanitizeType: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    customAttrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    customTagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 

+ 4 - 4
apps/app/src/pages/_private-legacy-pages.page.tsx

@@ -76,7 +76,7 @@ const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
         <title>{title}</title>
       </Head>
 
-      <DrawioViewerScript />
+      <DrawioViewerScript drawioUri={props.rendererConfig.drawioUri} />
 
       <SearchResultLayout>
         <div id="private-regacy-pages">
@@ -114,9 +114,9 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-    attrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
-    tagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+    sanitizeType: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    customAttrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    customTagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }

+ 4 - 4
apps/app/src/pages/_search.page.tsx

@@ -108,7 +108,7 @@ const Layout = ({ children, ...props }: LayoutProps): JSX.Element => {
 SearchResultPage.getLayout = function getLayout(page) {
   return (
     <>
-      <DrawioViewerScript />
+      <DrawioViewerScript drawioUri={page.props.rendererConfig.drawioUri} />
       <Layout {...page.props}>{page}</Layout>
     </>
   );
@@ -141,9 +141,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-    attrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
-    tagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+    sanitizeType: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    customAttrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    customTagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 

+ 3 - 3
apps/app/src/pages/me/[[...path]].page.tsx

@@ -196,9 +196,9 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-    attrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
-    tagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+    sanitizeType: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    customAttrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    customTagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }

+ 5 - 5
apps/app/src/pages/share/[[...path]].page.tsx

@@ -141,7 +141,7 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
 SharedPage.getLayout = function getLayout(page) {
   return (
     <>
-      <DrawioViewerScript />
+      <DrawioViewerScript drawioUri={page.props.rendererConfig.drawioUri} />
       <ShareLinkLayout>{page}</ShareLinkLayout>
     </>
   );
@@ -173,10 +173,10 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-    attrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
-    tagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
-    highlightJsStyleBorder: configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+    sanitizeType: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    customAttrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    customTagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 
   props.ssrMaxRevisionBodyLength = configManager.getConfig('crowi', 'app:ssrMaxRevisionBodyLength');

+ 0 - 10
apps/app/src/server/.node-dev.json

@@ -1,10 +0,0 @@
-{
-  "ignore": [
-    "package.json",
-    ".next",
-    "public/static",
-
-    "// ignore watching preset theme updates",
-    "packages/preset-themes/dist/themes/.vite/manifest.json"
-  ]
-}

+ 1 - 15
apps/app/src/server/crowi/index.js

@@ -14,7 +14,6 @@ import { KeycloakUserGroupSyncService } from '~/features/external-user-group/ser
 import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
 import QuestionnaireService from '~/features/questionnaire/server/service/questionnaire';
 import QuestionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
-import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 
@@ -81,7 +80,6 @@ class Crowi {
     this.mailService = null;
     this.passportService = null;
     this.globalNotificationService = null;
-    this.xssService = null;
     this.aclService = null;
     this.appService = null;
     this.fileUploadService = null;
@@ -97,7 +95,6 @@ class Crowi {
     this.inAppNotificationService = null;
     this.activityService = null;
     this.commentService = null;
-    this.xss = new Xss();
     this.questionnaireService = null;
     this.questionnaireCronService = null;
 
@@ -133,12 +130,11 @@ Crowi.prototype.init = async function() {
   await this.setupS2sMessagingService();
   await this.setupSocketIoService();
 
-  // customizeService depends on AppService and XssService
+  // customizeService depends on AppService
   // passportService depends on appService
   // export and import depends on setUpGrowiBridge
   await Promise.all([
     this.setUpApp(),
-    this.setUpXss(),
     this.setUpGrowiBridge(),
   ]);
 
@@ -597,16 +593,6 @@ Crowi.prototype.setUpUserNotification = async function() {
   }
 };
 
-/**
- * setup XssService
- */
-Crowi.prototype.setUpXss = async function() {
-  const XssService = require('../service/xss');
-  if (this.xssService == null) {
-    this.xssService = new XssService(this.configManager);
-  }
-};
-
 /**
  * setup AclService
  */

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

@@ -3,7 +3,8 @@ import type { Types } from 'mongoose';
 import { Schema } from 'mongoose';
 import uniqueValidator from 'mongoose-unique-validator';
 
-import { RehypeSanitizeOption } from '../../interfaces/rehype';
+import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
+
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
@@ -161,7 +162,7 @@ export const defaultMarkdownConfigs: { [key: string]: any } = {
   'markdown:xss:attrWhitelist': [],
 
   'markdown:rehypeSanitize:isEnabledPrevention': true,
-  'markdown:rehypeSanitize:option': RehypeSanitizeOption.RECOMMENDED,
+  'markdown:rehypeSanitize:option': RehypeSanitizeType.RECOMMENDED,
   'markdown:rehypeSanitize:tagNames': [],
   'markdown:rehypeSanitize:attributes': '{}',
   'markdown:isEnabledLinebreaks': false,

+ 3 - 20
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -11,18 +11,15 @@ import mongoose from 'mongoose';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { type IApiv3PageUpdateParams, PageUpdateErrorCode } from '~/interfaces/apiv3';
 import type { IOptionsForUpdate } from '~/interfaces/page';
-import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import type Crowi from '~/server/crowi';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import {
   GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely, serializeUserSecurely,
 } from '~/server/models';
 import type { PageDocument, PageModel } from '~/server/models/page';
-import { configManager } from '~/server/service/config-manager';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { getYjsConnectionManager } from '~/server/service/yjs-connection-manager';
-import Xss from '~/services/xss';
-import XssOption from '~/services/xss/xssOption';
+import { generalXssFilter } from '~/services/general-xss-filter';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
@@ -47,20 +44,6 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
   const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
   const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
 
-
-  const xss = (() => {
-    const initializedConfig = {
-      isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
-      tagWhitelist: crowi.xssService.getTagWhitelist(),
-      attrWhitelist: crowi.xssService.getAttrWhitelist(),
-      // TODO: Omit rehype related property from XssOptionConfig type
-      //  Server side xss implementation does not require it.
-      xssOption: RehypeSanitizeOption.CUSTOM,
-    };
-    const xssOption = new XssOption(initializedConfig);
-    return new Xss(xssOption);
-  })();
-
   // define validators for req.body
   const validator: ValidationChain[] = [
     body('pageId').exists().not().isEmpty({ ignore_whitespace: true })
@@ -138,7 +121,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
         pageId, revisionId, body, origin,
       } = req.body;
 
-      const sanitizeRevisionId = revisionId == null ? undefined : xss.process(revisionId);
+      const sanitizeRevisionId = revisionId == null ? undefined : generalXssFilter.process(revisionId);
 
       // check page existence
       const isExist = await Page.count({ _id: pageId }) > 0;
@@ -153,7 +136,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
         const latestRevision = await Revision.findById(currentPage.revision).populate('author');
         const returnLatestRevision = {
           revisionId: latestRevision?._id.toString(),
-          revisionBody: xss.process(latestRevision?.body),
+          revisionBody: latestRevision?.body,
           createdAt: latestRevision?.createdAt,
           user: serializeUserSecurely(latestRevision?.author),
         };

+ 3 - 2
apps/app/src/server/routes/apiv3/user-group.js

@@ -7,6 +7,7 @@ import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/
 import UserGroup from '~/server/models/user-group';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+import { generalXssFilter } from '~/services/general-xss-filter';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -230,8 +231,8 @@ module.exports = (crowi) => {
     const { name, description = '', parentId } = req.body;
 
     try {
-      const userGroupName = crowi.xss.process(name);
-      const userGroupDescription = crowi.xss.process(description);
+      const userGroupName = generalXssFilter.process(name);
+      const userGroupDescription = generalXssFilter.process(description);
       const userGroup = await UserGroup.createGroup(userGroupName, userGroupDescription, parentId);
 
       const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_CREATE };

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

@@ -1,14 +1,12 @@
 import { body } from 'express-validator';
 import mongoose from 'mongoose';
 
-import XssOption from '~/services/xss/xssOption';
 import loggerFactory from '~/utils/logger';
 
 import { GlobalNotificationSettingEvent } from '../models';
 import { PathAlreadyExistsError } from '../models/errors';
 import PageTagRelation from '../models/page-tag-relation';
 import UpdatePost from '../models/update-post';
-import { configManager } from '../service/config-manager';
 
 /**
  * @swagger
@@ -146,19 +144,8 @@ module.exports = function(crowi, app) {
 
   const ApiResponse = require('../util/apiResponse');
 
-  const { xssService } = crowi;
   const globalNotificationService = crowi.getGlobalNotificationService();
 
-  const Xss = require('~/services/xss/index');
-  const initializedConfig = {
-    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
-    tagWhitelist: xssService.getTagWhitelist(),
-    attrWhitelist: xssService.getAttrWhitelist(),
-  };
-  const xssOption = new XssOption(initializedConfig);
-  const xss = new Xss(xssOption);
-
-
   const actions = {};
 
   // async function showPageForPresentation(req, res, next) {

+ 1 - 28
apps/app/src/server/service/customize.ts

@@ -1,7 +1,6 @@
 import path from 'path';
 
 import type { ColorScheme } from '@growi/core';
-import { DevidedPagePath } from '@growi/core/dist/models';
 import { getForcedColorScheme } from '@growi/core/dist/utils';
 import { DefaultThemeMetadata, PresetThemesMetadatas, manifestPath } from '@growi/preset-themes';
 import uglifycss from 'uglifycss';
@@ -11,6 +10,7 @@ import loggerFactory from '~/utils/logger';
 
 import S2sMessage from '../models/vo/s2s-message';
 
+
 import type { ConfigManager } from './config-manager';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
@@ -29,8 +29,6 @@ class CustomizeService implements S2sMessageHandlable {
 
   appService: any;
 
-  xssService: any;
-
   lastLoadedAt?: Date;
 
   customCss?: string;
@@ -47,7 +45,6 @@ class CustomizeService implements S2sMessageHandlable {
     this.configManager = crowi.configManager;
     this.s2sMessagingService = crowi.s2sMessagingService;
     this.appService = crowi.appService;
-    this.xssService = crowi.xssService;
   }
 
   /**
@@ -126,30 +123,6 @@ class CustomizeService implements S2sMessageHandlable {
     this.lastLoadedAt = new Date();
   }
 
-  generateCustomTitle(pageOrPath) {
-    const path = pageOrPath.path || pageOrPath;
-    const dPagePath = new DevidedPagePath(path, true, true);
-
-    const customTitle = this.customTitleTemplate
-      .replace('{{sitename}}', this.appService.getAppTitle())
-      .replace('{{pagepath}}', path)
-      .replace('{{page}}', dPagePath.latter) // for backward compatibility
-      .replace('{{pagename}}', dPagePath.latter);
-
-    return this.xssService.process(customTitle);
-  }
-
-  generateCustomTitleForFixedPageName(title) {
-    // replace
-    const customTitle = this.customTitleTemplate
-      .replace('{{sitename}}', this.appService.getAppTitle())
-      .replace('{{page}}', title)
-      .replace('{{pagepath}}', title)
-      .replace('{{pagename}}', title);
-
-    return this.xssService.process(customTitle);
-  }
-
   async initGrowiTheme(): Promise<void> {
     const theme = this.configManager.getConfig('crowi', 'customize:theme');
 

+ 2 - 4
apps/app/src/server/service/file-uploader/aws.ts

@@ -180,10 +180,8 @@ class AwsFileUploader extends AbstractFileUploader {
 
       // eslint-disable-next-line no-nested-ternary
       return 'stream' in body
-        ? body.stream() // get stream from Blob
-        : !('read' in body)
-          ? body as unknown as NodeJS.ReadableStream // cast force
-          : body;
+        ? body.stream() as unknown as NodeJS.ReadableStream // get stream from Blob and cast force
+        : body as unknown as NodeJS.ReadableStream; // cast force
     }
     catch (err) {
       logger.error(err);

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

@@ -44,6 +44,7 @@ import type { UserGroupDocument } from '~/server/models/user-group';
 import { getYjsConnectionManager } from '~/server/service/yjs-connection-manager';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { collectAncestorPaths } from '~/server/util/collect-ancestor-paths';
+import { generalXssFilter } from '~/services/general-xss-filter';
 import loggerFactory from '~/utils/logger';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
@@ -610,7 +611,7 @@ class PageService implements IPageService {
 
     const updateMetadata = options.updateMetadata || false;
     // sanitize path
-    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
+    newPagePath = generalXssFilter.process(newPagePath); // eslint-disable-line no-param-reassign
 
     // UserGroup & Owner validation
     // use the parent's grant when target page is an empty page
@@ -839,7 +840,7 @@ class PageService implements IPageService {
     } = options;
 
     // sanitize path
-    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
+    newPagePath = generalXssFilter.process(newPagePath); // eslint-disable-line no-param-reassign
 
     // create descendants first
     if (isRecursively) {
@@ -1104,7 +1105,7 @@ class PageService implements IPageService {
       throw Error('Page not found.');
     }
 
-    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
+    newPagePath = generalXssFilter.process(newPagePath); // eslint-disable-line no-param-reassign
 
     // 1. Separate v4 & v5 process
     const isShouldUseV4Process = shouldUseV4Process(page);
@@ -1278,7 +1279,7 @@ class PageService implements IPageService {
     options.grantUserGroupIds = page.grantedGroups;
     options.grantedUserIds = page.grantedUsers;
 
-    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
+    newPagePath = generalXssFilter.process(newPagePath); // eslint-disable-line no-param-reassign
 
     const createdPage = await this.create(
       newPagePath, page.revision.body, user, options,
@@ -3777,7 +3778,7 @@ class PageService implements IPageService {
     }
 
     // Values
-    const path: string = this.crowi.xss.process(_path); // sanitize path
+    const path: string = generalXssFilter.process(_path); // sanitize path
 
     // Retrieve closest ancestor document
     const Page = mongoose.model<PageDocument, PageModel>('Page');
@@ -3907,7 +3908,7 @@ class PageService implements IPageService {
     const expandContentWidth = this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
 
     // sanitize path
-    path = this.crowi.xss.process(path); // eslint-disable-line no-param-reassign
+    path = generalXssFilter.process(path); // eslint-disable-line no-param-reassign
 
     let grant = options.grant;
     // force public
@@ -3988,7 +3989,7 @@ class PageService implements IPageService {
 
     // Values
     // eslint-disable-next-line no-param-reassign
-    path = this.crowi.xss.process(path); // sanitize path
+    path = generalXssFilter.process(path); // sanitize path
 
     const {
       grantUserGroupIds, grantUserIds,
@@ -4396,7 +4397,6 @@ class PageService implements IPageService {
       .lean()
       .exec();
 
-    this.injectIsTargetIntoPages(pages, path);
     await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
 
     /*
@@ -4416,14 +4416,6 @@ class PageService implements IPageService {
     return pathToChildren;
   }
 
-  private injectIsTargetIntoPages(pages: (PageDocument & {isTarget?: boolean})[], path): void {
-    pages.forEach((page) => {
-      if (page.path === path) {
-        page.isTarget = true;
-      }
-    });
-  }
-
   /**
    * Inject processData into page docuements
    * The processData is a combination of actionType as a key and information on whether the action is processable as a value.

+ 2 - 1
apps/app/src/server/service/slack-command-handler/create-page-service.js

@@ -1,6 +1,7 @@
 import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder';
 import { reshapeContentsBody } from '@growi/slack/dist/utils/reshape-contents-body';
 
+import { generalXssFilter } from '~/services/general-xss-filter';
 import loggerFactory from '~/utils/logger';
 
 // eslint-disable-next-line no-unused-vars
@@ -19,7 +20,7 @@ class CreatePageService {
     const reshapedContentsBody = reshapeContentsBody(contentsBody);
 
     // sanitize path
-    const sanitizedPath = this.crowi.xss.process(path);
+    const sanitizedPath = generalXssFilter.process(path);
     const normalizedPath = pathUtils.normalizePath(sanitizedPath);
 
     // Since an ObjectId is required for creating a page, if a user does not exist, a dummy user will be generated

+ 0 - 73
apps/app/src/server/service/xss.js

@@ -1,73 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:service:XssSerivce'); // eslint-disable-line no-unused-vars
-
-const Xss = require('~/services/xss');
-const { tags, attrs } = require('~/services/xss/recommended-whitelist');
-
-/**
- * the service class of XssSerivce
- */
-class XssSerivce {
-
-  constructor(configManager) {
-    this.configManager = configManager;
-
-    this.xss = new Xss();
-  }
-
-  process(value) {
-    return this.xss.process(value);
-  }
-
-  getTagWhitelist() {
-    const isEnabledXssPrevention = this.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention');
-    const xssOpiton = this.configManager.getConfig('markdown', 'markdown:xss:option');
-
-    if (isEnabledXssPrevention) {
-      switch (xssOpiton) {
-        case 1: // ignore all: use default option
-          return [];
-
-        case 2: // recommended
-          return tags;
-
-        case 3: // custom whitelist
-          return this.configManager.getConfig('markdown', 'markdown:xss:tagWhitelist');
-
-        default:
-          return [];
-      }
-    }
-    else {
-      return [];
-    }
-  }
-
-  getAttrWhitelist() {
-    const isEnabledXssPrevention = this.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention');
-    const xssOpiton = this.configManager.getConfig('markdown', 'markdown:xss:option');
-
-    if (isEnabledXssPrevention) {
-      switch (xssOpiton) {
-        case 1: // ignore all: use default option
-          return [];
-
-        case 2: // recommended
-          return attrs;
-
-        case 3: // custom whitelist
-          return this.configManager.getConfig('markdown', 'markdown:xss:attrWhitelist');
-
-        default:
-          return [];
-      }
-    }
-    else {
-      return [];
-    }
-  }
-
-}
-
-module.exports = XssSerivce;

+ 39 - 0
apps/app/src/services/general-xss-filter/general-xss-filter.spec.ts

@@ -0,0 +1,39 @@
+import { generalXssFilter } from './general-xss-filter';
+
+describe('generalXssFilter', () => {
+
+  test('should be sanitize script tag', () => {
+    // Act
+    const result = generalXssFilter.process('<script>alert("XSS")</script>');
+
+    // Assert
+    expect(result).toBe('alert("XSS")');
+  });
+
+  test('should be sanitize nested script tag recursively', () => {
+    // Act
+    const result = generalXssFilter.process('<scr<script>ipt>alert("XSS")</scr<script>ipt>');
+
+    // Assert
+    expect(result).toBe('alert("XSS")');
+  });
+
+  // for https://github.com/weseek/growi/issues/221
+  test('should not be sanitize blockquote', () => {
+    // Act
+    const result = generalXssFilter.process('> foo\n> bar');
+
+    // Assert
+    expect(result).toBe('> foo\n> bar');
+  });
+
+  // https://github.com/weseek/growi/pull/505
+  test('should not be sanitize next closing-tag', () => {
+    // Act
+    const result = generalXssFilter.process('<evil /><span>text</span>');
+
+    // Assert
+    expect(result).toBe('<span>text</span>');
+  });
+
+});

+ 37 - 0
apps/app/src/services/general-xss-filter/general-xss-filter.ts

@@ -0,0 +1,37 @@
+import type { IFilterXSSOptions } from 'xss';
+import { FilterXSS } from 'xss';
+
+const REPETITIONS_NUM = 50;
+
+const option: IFilterXSSOptions = {
+  stripIgnoreTag: true,
+  stripIgnoreTagBody: false, // see https://github.com/weseek/growi/pull/505
+  css: false,
+  escapeHtml: (html) => { return html }, // resolve https://github.com/weseek/growi/issues/221
+};
+
+class GeneralXssFilter extends FilterXSS {
+
+  override process(document: string | undefined): string {
+    let count = 0;
+    let currDoc = document;
+    let prevDoc = document;
+
+    do {
+      count += 1;
+      // stop running infinitely
+      if (count > REPETITIONS_NUM) {
+        return '--filtered--';
+      }
+
+      prevDoc = currDoc;
+      currDoc = super.process(currDoc ?? '');
+    }
+    while (currDoc !== prevDoc);
+
+    return currDoc;
+  }
+
+}
+
+export const generalXssFilter = new GeneralXssFilter(option);

+ 1 - 0
apps/app/src/services/general-xss-filter/index.ts

@@ -0,0 +1 @@
+export * from './general-xss-filter';

+ 38 - 0
apps/app/src/services/renderer/recommended-whitelist.spec.ts

@@ -0,0 +1,38 @@
+import { tagNames, attributes } from './recommended-whitelist';
+
+describe('recommended-whitelist', () => {
+
+  test('.tagNames should return iframe tag', () => {
+    expect(tagNames).not.toBeNull();
+    expect(tagNames).includes('iframe');
+  });
+
+  test('.tagNames should return video tag', () => {
+    expect(tagNames).not.toBeNull();
+    expect(tagNames).includes('video');
+  });
+
+  test('.attributes should return data attributes', () => {
+    expect(attributes).not.toBeNull();
+    expect(Object.keys(attributes)).includes('*');
+    expect(attributes['*']).includes('alt');
+    expect(attributes['*']).includes('align');
+    expect(attributes['*']).includes('width');
+    expect(attributes['*']).includes('height');
+    expect(attributes['*']).includes('className');
+    expect(attributes['*']).includes('data*');
+  });
+
+  test('.attributes should return iframe attributes', () => {
+    expect(attributes).not.toBeNull();
+    expect(Object.keys(attributes)).includes('iframe');
+    expect(attributes.iframe).includes('src');
+  });
+
+  test('.attributes should return video attributes', () => {
+    expect(attributes).not.toBeNull();
+    expect(Object.keys(attributes)).includes('video');
+    expect(attributes.iframe).includes('src');
+  });
+
+});

+ 29 - 0
apps/app/src/services/renderer/recommended-whitelist.ts

@@ -0,0 +1,29 @@
+import { defaultSchema } from 'hast-util-sanitize';
+import type { Attributes } from 'hast-util-sanitize/lib';
+import deepmerge from 'ts-deepmerge';
+
+/**
+ * reference: https://meta.stackexchange.com/questions/1777/what-html-tags-are-allowed-on-stack-exchange-sites,
+ *            https://github.com/jch/html-pipeline/blob/70b6903b025c668ff3c02a6fa382031661182147/lib/html/pipeline/sanitization_filter.rb#L41
+ */
+
+export const tagNames: Array<string> = [
+  ...defaultSchema.tagNames ?? [],
+  '-', 'bdi',
+  'col', 'colgroup',
+  'data',
+  'iframe',
+  'video',
+  'rb', 'u',
+];
+
+export const attributes: Attributes = deepmerge(
+  defaultSchema.attributes ?? {},
+  {
+    iframe: ['allow', 'referrerpolicy', 'sandbox', 'src', 'srcdoc'],
+    video: ['controls', 'src', 'muted', 'preload', 'width', 'height', 'autoplay'],
+    // The special value 'data*' as a property name can be used to allow all data properties.
+    // see: https://github.com/syntax-tree/hast-util-sanitize/
+    '*': ['key', 'class', 'className', 'style', 'data*'],
+  },
+);

+ 12 - 23
apps/app/src/services/renderer/renderer.tsx

@@ -2,7 +2,7 @@ import growiDirective from '@growi/remark-growi-directive';
 import type { Schema as SanitizeOption } from 'hast-util-sanitize';
 import katex from 'rehype-katex';
 import raw from 'rehype-raw';
-import sanitize, { defaultSchema as rehypeSanitizeDefaultSchema } from 'rehype-sanitize';
+import sanitize from 'rehype-sanitize';
 import slug from 'rehype-slug';
 import breaks from 'remark-breaks';
 import emoji from 'remark-emoji';
@@ -16,17 +16,19 @@ import type { Pluggable, PluginTuple } from 'unified';
 
 import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
-import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import type { RendererOptions } from '~/interfaces/renderer-options';
+import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import loggerFactory from '~/utils/logger';
 
+import { tagNames as recommendedTagNames, attributes as recommendedAttributes } from './recommended-whitelist';
 import * as addClass from './rehype-plugins/add-class';
 import { relativeLinks } from './rehype-plugins/relative-links';
 import { relativeLinksByPukiwikiLikeLinker } from './rehype-plugins/relative-links-by-pukiwiki-like-linker';
 import { pukiwikiLikeLinker } from './remark-plugins/pukiwiki-like-linker';
 import * as xsvToTable from './remark-plugins/xsv-to-table';
 
+
 // import EasyGrid from './PreProcessor/EasyGrid';
 
 
@@ -36,31 +38,18 @@ const logger = loggerFactory('growi:services:renderer');
 
 type SanitizePlugin = PluginTuple<[SanitizeOption]>;
 
-const baseSanitizeSchema = {
-  tagNames: ['iframe', 'section', 'video'],
-  attributes: {
-    iframe: ['allow', 'referrerpolicy', 'sandbox', 'src', 'srcdoc'],
-    video: ['controls', 'src', 'muted', 'preload', 'width', 'height', 'autoplay'],
-    // The special value 'data*' as a property name can be used to allow all data properties.
-    // see: https://github.com/syntax-tree/hast-util-sanitize/
-    '*': ['key', 'class', 'className', 'style', 'data*'],
-  },
+export const commonSanitizeOption: SanitizeOption = {
+  tagNames: recommendedTagNames,
+  attributes: recommendedAttributes,
+  clobberPrefix: '', // remove clobber prefix
 };
 
-export const commonSanitizeOption: SanitizeOption = deepmerge(
-  rehypeSanitizeDefaultSchema,
-  baseSanitizeSchema,
-  {
-    clobberPrefix: '', // remove clobber prefix
-  },
-);
-
 let isInjectedCustomSanitaizeOption = false;
 
 export const injectCustomSanitizeOption = (config: RendererConfig): void => {
-  if (!isInjectedCustomSanitaizeOption && config.isEnabledXssPrevention && config.xssOption === RehypeSanitizeOption.CUSTOM) {
-    commonSanitizeOption.tagNames = baseSanitizeSchema.tagNames.concat(config.tagWhitelist ?? []);
-    commonSanitizeOption.attributes = deepmerge(baseSanitizeSchema.attributes, config.attrWhitelist ?? {});
+  if (!isInjectedCustomSanitaizeOption && config.isEnabledXssPrevention && config.sanitizeType === RehypeSanitizeType.CUSTOM) {
+    commonSanitizeOption.tagNames = config.customTagWhitelist ?? recommendedTagNames;
+    commonSanitizeOption.attributes = config.customAttrWhitelist ?? recommendedAttributes;
     isInjectedCustomSanitaizeOption = true;
   }
 };
@@ -142,7 +131,7 @@ export const generateSSRViewOptions = (
     remarkPlugins.push(breaks);
   }
 
-  if (config.xssOption === RehypeSanitizeOption.CUSTOM) {
+  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
     injectCustomSanitizeOption(config);
   }
 

+ 0 - 42
apps/app/src/services/xss/commonmark-spec.js

@@ -1,42 +0,0 @@
-/**
- * Valid schemes
- * @see https://spec.commonmark.org/0.16/#autolinks
- */
-const schemesForAutolink = [
-  'coap', 'doi', 'javascript', 'aaa', 'aaas', 'about', 'acap', 'cap', 'cid', 'crid', 'data', 'dav', 'dict', 'dns',
-  'file', 'ftp', 'geo', 'go', 'gopher', 'h323', 'http', 'https', 'iax', 'icap', 'im', 'imap', 'info', 'ipp', 'iris',
-  'iris.beep', 'iris.xpc', 'iris.xpcs', 'iris.lwz', 'ldap', 'mailto', 'mid', 'msrp', 'msrps', 'mtqp', 'mupdate',
-  'news', 'nfs', 'ni', 'nih', 'nntp', 'opaquelocktoken', 'pop', 'pres', 'rtsp', 'service', 'session', 'shttp',
-  'sieve', 'sip', 'sips', 'sms', 'snmp,soap.beep', 'soap.beeps', 'tag', 'tel', 'telnet', 'tftp', 'thismessage',
-  'tn3270', 'tip', 'tv', 'urn', 'vemmi', 'ws', 'wss', 'xcon', 'xcon-userid', 'xmlrpc.beep', 'xmlrpc.beeps', 'xmpp',
-  'z39.50r', 'z39.50s', 'adiumxtra', 'afp', 'afs', 'aim', 'apt,attachment', 'aw', 'beshare', 'bitcoin', 'bolo',
-  'callto', 'chrome,chrome-extension', 'com-eventbrite-attendee', 'content', 'cvs,dlna-playsingle', 'dlna-playcontainer',
-  'dtn', 'dvb', 'ed2k', 'facetime', 'feed', 'finger', 'fish', 'gg', 'git', 'gizmoproject', 'gtalk', 'hcp', 'icon',
-  'ipn', 'irc', 'irc6', 'ircs', 'itms', 'jar', 'jms', 'keyparc', 'lastfm', 'ldaps', 'magnet', 'maps', 'market,message',
-  'mms', 'ms-help', 'msnim', 'mumble', 'mvn', 'notes', 'oid', 'palm', 'paparazzi', 'platform', 'proxy', 'psyc',
-  'query', 'res', 'resource', 'rmi', 'rsync', 'rtmp', 'secondlife', 'sftp', 'sgn', 'skype', 'smb', 'soldat', 'spotify',
-  'ssh', 'steam', 'svn', 'teamspeak', 'things', 'udp', 'unreal', 'ut2004', 'ventrilo', 'view-source', 'webcal',
-  'wtai', 'wyciwyg', 'xfire', 'xri', 'ymsgr',
-];
-const schemesCondition = schemesForAutolink.join('|');
-
-/**
- * RegExp for URI
- * @type {RegExp}
- * @see https://spec.commonmark.org/0.16/#autolinks
- */
-const uriAutolinkRegexp = new RegExp(`^(${schemesCondition}):\\/\\/.+$`);
-
-/**
- * RegExp for email
- * @type {RegExp}
- * @see https://spec.commonmark.org/0.16/#autolinks
- */
-// eslint-disable-next-line max-len
-const emailAutolinkRegexp = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
-
-
-module.exports = {
-  uriAutolinkRegexp,
-  emailAutolinkRegexp,
-};

+ 0 - 63
apps/app/src/services/xss/index.js

@@ -1,63 +0,0 @@
-const xss = require('xss');
-const commonmarkSpec = require('./commonmark-spec');
-
-
-const REPETITIONS_NUM = 50;
-
-class Xss {
-
-  constructor(xssOption) {
-
-    xssOption = xssOption || {}; // eslint-disable-line no-param-reassign
-
-    const tagWhitelist = xssOption.tagWhitelist || [];
-    const attrWhitelist = xssOption.attrWhitelist || [];
-
-    const whitelistContent = {};
-
-    // default
-    const option = {
-      stripIgnoreTag: true,
-      stripIgnoreTagBody: false, // see https://github.com/weseek/growi/pull/505
-      css: false,
-      whitelist: whitelistContent,
-      escapeHtml: (html) => { return html }, // resolve https://github.com/weseek/growi/issues/221
-      onTag: (tag, html, options) => {
-        // pass autolink
-        if (tag.match(commonmarkSpec.uriAutolinkRegexp) || tag.match(commonmarkSpec.emailAutolinkRegexp)) {
-          return html;
-        }
-      },
-    };
-
-    tagWhitelist.forEach((tag) => {
-      whitelistContent[tag] = attrWhitelist;
-    });
-
-    // create the XSS Filter instance
-    this.myxss = new xss.FilterXSS(option);
-  }
-
-  process(document) {
-    let count = 0;
-    let currDoc = document;
-    let prevDoc = document;
-
-    do {
-      count += 1;
-      // stop running infinitely
-      if (count > REPETITIONS_NUM) {
-        return '--filtered--';
-      }
-
-      prevDoc = currDoc;
-      currDoc = this.myxss.process(currDoc);
-    }
-    while (currDoc !== prevDoc);
-
-    return currDoc;
-  }
-
-}
-
-module.exports = Xss;

+ 0 - 21
apps/app/src/services/xss/recommended-whitelist.js

@@ -1,21 +0,0 @@
-/**
- * reference: https://meta.stackexchange.com/questions/1777/what-html-tags-are-allowed-on-stack-exchange-sites,
- *            https://github.com/jch/html-pipeline/blob/70b6903b025c668ff3c02a6fa382031661182147/lib/html/pipeline/sanitization_filter.rb#L41
- */
-
-const tags = [
-  '-', 'a', 'abbr', 'b', 'bdi', 'bdo', 'blockquote', 'br', 'caption', 'cite',
-  'code', 'col', 'colgroup', 'data', 'dd', 'del', 'details', 'dfn', 'div', 'dl',
-  'dt', 'em', 'figcaption', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'h7',
-  'h8', 'hr', 'i', 'iframe', 'img', 'ins', 'kbd', 'li', 'mark', 'ol', 'p',
-  'pre', 'q', 'rb', 'rp', 'rt', 'ruby', 's', 'samp', 'small', 'span', 'strike',
-  'strong', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th',
-  'thead', 'time', 'tr', 'tt', 'u', 'ul', 'var', 'wbr',
-];
-
-const attrs = ['src', 'href', 'class', 'id', 'width', 'height', 'alt', 'title', 'style'];
-
-module.exports = {
-  tags,
-  attrs,
-};

+ 0 - 32
apps/app/src/services/xss/xssOption.ts

@@ -1,32 +0,0 @@
-import { defaultSchema as sanitizeDefaultSchema } from 'rehype-sanitize';
-
-import type { RehypeSanitizeOption } from '~/interfaces/rehype';
-
-type tagWhitelist = typeof sanitizeDefaultSchema.tagNames;
-type attrWhitelist = typeof sanitizeDefaultSchema.attributes;
-
-export type XssOptionConfig = {
-  isEnabledXssPrevention: boolean,
-  xssOption: RehypeSanitizeOption,
-  tagWhitelist: tagWhitelist,
-  attrWhitelist: attrWhitelist,
-}
-
-export default class XssOption {
-
-  isEnabledXssPrevention: boolean;
-
-  tagWhitelist: any[];
-
-  attrWhitelist: any[];
-
-  constructor(config: XssOptionConfig) {
-    const recommendedWhitelist = require('~/services/xss/recommended-whitelist');
-    const initializedConfig: Partial<XssOptionConfig> = (config != null) ? config : {};
-
-    this.isEnabledXssPrevention = initializedConfig.isEnabledXssPrevention || true;
-    this.tagWhitelist = initializedConfig.tagWhitelist || recommendedWhitelist.tags;
-    this.attrWhitelist = initializedConfig.attrWhitelist || recommendedWhitelist.attrs;
-  }
-
-}

+ 13 - 0
apps/app/src/stores/ui.tsx

@@ -510,3 +510,16 @@ export const useIsAbleToShowPageAuthors = (): SWRResponse<boolean, Error> => {
     () => isPageExist && !isUsersTopPagePath,
   );
 };
+
+export const useIsUntitledPage = (): SWRResponse<boolean> => {
+  const key = 'isUntitledPage';
+
+  const { data: pageId } = useCurrentPageId();
+
+  return useSWRStatic(
+    pageId == null ? null : [key, pageId],
+    undefined,
+    { fallbackData: false },
+  );
+
+};

+ 0 - 10
apps/app/src/stores/xss.ts

@@ -1,10 +0,0 @@
-
-import { SWRResponse } from 'swr';
-
-import Xss from '~/services/xss';
-
-import { useStaticSWR } from './use-static-swr';
-
-export const useXss = (initialData?: Xss): SWRResponse<Xss, Error> => {
-  return useStaticSWR<Xss, Error>('xss', initialData);
-};

+ 12 - 0
apps/app/src/styles/_layout.scss

@@ -65,6 +65,18 @@ body {
       --bs-gutter-x: 3rem;
     }
   }
+
+  // set to double value to allow space for .revision-head-link
+  @include bs.media-breakpoint-up(xl) {
+    &,
+    .container,
+    .container-fluid,
+    .container-xxl,
+    .container-xl,
+    .container-lg {
+      padding-left: calc(var(--bs-gutter-x) * 1);
+    }
+  }
 }
 
 // printable style

+ 58 - 26
apps/app/src/styles/organisms/_wiki.scss

@@ -13,79 +13,104 @@
 
   font-size: 16px;
 
+  // @extend .text-break;
+  // https://github.com/twbs/bootstrap/blob/v4.6.1/scss/utilities/_text.scss#L65-L68
+  word-break: break-word !important; // Deprecated, but avoids issues with flex containers
+  word-wrap: break-word !important; // Used instead of `overflow-wrap` for IE & Edge Legacy
+
   a {
     @extend .link-offset-2;
 
     text-decoration-line: underline;
   }
 
-  // @extend .text-break;
-  // https://github.com/twbs/bootstrap/blob/v4.6.1/scss/utilities/_text.scss#L65-L68
-  word-break: break-word !important; // Deprecated, but avoids issues with flex containers
-  word-wrap: break-word !important; // Used instead of `overflow-wrap` for IE & Edge Legacy
-
   h1,
   h2,
   h3,
   h4,
   h5,
-  h6 {
+  h6,
+  .h1,
+  .h2,
+  .h3,
+  .h4,
+  .h5,
+  .h6 {
     margin-top: 1.6em;
     margin-bottom: 0.8em;
 
-    &:first-child {
-      margin-top: 0;
-    }
-
     scroll-margin-top: var.$grw-scroll-margin-top-in-view;
   }
 
-  /* stylelint-disable no-descending-specificity */
-  h1 {
+  h1, .h1 {
     padding: 0.3em 0;
     margin-top: 2em;
     font-size: 1.9em;
     line-height: 1.1em;
+  }
+
+  h1 {
     border-bottom: 2px solid var(--bs-border-color);
   }
 
-  h2 {
+  h2, .h2 {
     padding-bottom: 0.3em;
     font-size: 1.6em;
     font-weight: bold;
     line-height: 1.225;
+  }
+  h2 {
     border-bottom: 1px solid var(--bs-border-color);
   }
 
-  h3 {
+  h3, .h3 {
     font-size: 1.4em;
     font-weight: bold;
   }
 
-  h4 {
+  h4, .h4 {
     font-size: 1.35em;
     font-weight: normal;
-
+  }
+  h4 {
     // style
     @include add-left-border(6px);
   }
 
-  h5 {
+  h5, .h5 {
     font-size: 1.25em;
     font-weight: normal;
-
+  }
+  h5 {
     // style
     @include add-left-border(4px);
   }
 
-  h6 {
+  h6, .h6 {
     font-size: 1.2em;
     font-weight: normal;
-
+  }
+  h6 {
     // style
     @include add-left-border(2px);
   }
-  /* stylelint-enable no-descending-specificity */
+
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6,
+  .h1,
+  .h2,
+  .h3,
+  .h4,
+  .h5,
+  .h6 {
+    &:first-child {
+      margin-top: 0;
+    }
+  }
 
   p {
     margin: 15px 0;
@@ -105,7 +130,7 @@
     border-left: 0.3rem solid #ddd;
   }
 
-  img {
+  img,video {
     max-width: 100%;
     margin: 5px 0;
   }
@@ -207,10 +232,6 @@
     h6 {
       margin-top: 1.6em * $ratio;
       margin-bottom: 0.8em * $ratio;
-
-      &:first-child {
-        margin-top: 15px;
-      }
     }
 
     /* stylelint-disable no-descending-specificity */
@@ -232,6 +253,17 @@
     }
     /* stylelint-enable no-descending-specificity */
 
+    h1,
+    h2,
+    h3,
+    h4,
+    h5,
+    h6 {
+      &:first-child {
+        margin-top: 15px;
+      }
+    }
+
     blockquote {
       font-size: 0.9em * $ratio;
     }

+ 0 - 58
apps/app/test/cypress/e2e/10-install/10-install--install.cy.ts

@@ -1,58 +0,0 @@
-describe('Install', () => {
-  const ssPrefix = 'installer-';
-
-  beforeEach(() => {
-    cy.visit('/');
-    cy.getByTestid('installerForm').should('be.visible');
-  });
-
-  it('Successfully show installer', () => {
-    cy.screenshot(`${ssPrefix}-redirect-to-installer-page`);
-  });
-
-  it('Sccessfully choose languages', () => {
-    cy.getByTestid('dropdownLanguage').should('be.visible');
-
-    // open Language Dropdown, wait for language data to load
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('dropdownLanguage').click();
-      // wati until
-      return cy.get('.dropdown-menu').then($elem => $elem.is(':visible'));
-    });
-
-    cy.getByTestid('dropdownLanguageMenu-en_US').click();
-    cy.get('.alert-success').should('be.visible');
-    cy.screenshot(`${ssPrefix}-select-en_US`);
-
-    cy.getByTestid('dropdownLanguage').click();
-    cy.get('.dropdown-menu').should('be.visible');
-    cy.getByTestid('dropdownLanguageMenu-ja_JP').click();
-    cy.get('.alert-success').should('be.visible');
-    cy.screenshot(`${ssPrefix}-select-ja_JP`);
-
-    cy.getByTestid('dropdownLanguage').click();
-    cy.get('.dropdown-menu').should('be.visible');
-    cy.getByTestid('dropdownLanguageMenu-zh_CN').click();
-    cy.get('.alert-success').should('be.visible');
-    cy.screenshot(`${ssPrefix}-select-zh_CN`);
-  });
-
-  it('Successfully installing and redirect to root page', () => {
-    cy.fixture("user-admin.json").then(user => {
-      cy.getByTestid('tiUsername').type(user.username);
-      cy.getByTestid('tiName').type(user.name);
-      cy.getByTestid('tiEmail').type(user.email);
-      cy.getByTestid('tiPassword').type(user.password);
-    });
-    cy.screenshot(`${ssPrefix}-before-submit`);
-
-    cy.getByTestid('btnSubmit').click();
-
-    // Redirects to the root page take a long time (more than 10000ms)
-    cy.getByTestid('grw-pagetree-item-container', { timeout: 20000 }).should('be.visible');
-
-    cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}-installed-redirect-to-root-page`);
-  });
-});

+ 0 - 38
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts

@@ -21,18 +21,6 @@ context('Access to page', () => {
     });
   });
 
-  it('/Sandbox is successfully loaded', () => {
-    cy.visit('/Sandbox');
-    cy.collapseSidebar(true, true);
-
-    // for check download toc data
-    // https://redmine.weseek.co.jp/issues/111384
-    // cy.get('.toc-link').should('be.visible');
-
-    cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}-sandbox`);
-  });
-
   // TODO: https://redmine.weseek.co.jp/issues/109939
   it('/Sandbox with anchor hash is successfully loaded', () => {
     cy.visit('/Sandbox#headers');
@@ -131,32 +119,6 @@ context('Access to page', () => {
 });
 
 
-context('Access to /me page', () => {
-  const ssPrefix = 'access-to-me-page-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it('/me is successfully loaded', () => {
-    cy.visit('/me');
-
-    cy.getByTestid('grw-user-settings').should('be.visible');
-
-    cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}-me`);
-  });
-
-  // it('Draft page is successfully shown', () => {
-  //   cy.visit('/me/drafts');
-  //   cy.screenshot(`${ssPrefix}-draft-page`);
-  // });
-
-});
-
 context('Access to special pages', () => {
   const ssPrefix = 'access-to-special-pages-';
 

+ 0 - 47
apps/app/test/cypress/e2e/23-editor/23-editor--saving.cy.ts

@@ -9,53 +9,6 @@ context('PageCreateButton', () => {
     });
   });
 
-  it("DropendMenu is shown successfully", () => {
-    cy.visit('/');
-    cy.collapseSidebar(true, true);
-
-    cy.getByTestid('grw-page-create-button').trigger('mouseover');
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('grw-page-create-button-dropend-toggle').click({force: true});
-      // wait until
-      return cy.getByTestid('grw-page-create-button-dropend-menu').then($elem => $elem.is(':visible'));
-    });
-
-    cy.screenshot(`${ssPrefix}page-create-button-dropend-menu-shown`);
-  });
-
-  it("Successfully Create Today's page", () => {
-    cy.visit('/');
-    cy.collapseSidebar(true);
-
-    cy.getByTestid('grw-page-create-button').trigger('mouseover');
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('grw-page-create-button-dropend-toggle').click({force: true});
-      // wait until
-      return cy.getByTestid('grw-page-create-button-dropend-menu').then($elem => $elem.is(':visible'));
-    });
-
-    cy.getByTestid('grw-page-create-button-dropend-menu').should('be.visible').within(() => {
-      cy.get('button').eq(1).click();
-    });
-
-    cy.getByTestid('page-editor').should('be.visible');
-    cy.getByTestid('save-page-btn').as('save-page-btn').should('be.visible');
-    cy.waitUntil(() => {
-      // do
-      cy.get('@save-page-btn').click();
-      // wait until
-      return cy.get('@save-page-btn').then($elem => $elem.is(':disabled'));
-    });
-    cy.get('.layout-root').should('not.have.class', 'editing');
-
-    cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}create-today-page`);
-  });
-
   it.skip('Successfully create page under specific path', () => {
     const pageName = 'child';
 

+ 0 - 124
apps/app/test/cypress/e2e/40-admin/40-admin--access-to-admin-page.cy.ts

@@ -1,124 +0,0 @@
-const adminMenues = [
-  'app', // App
-  'security', // Security
-  'security', // Security
-  'security', // Security
-  'security', // Security
-  'security', // Security
-  'security', // Security
-  'security', // Security
-  'security', // Security
-  'security', // Security
-  'security', // Security
-];
-
-context('Access to Admin page', () => {
-  const ssPrefix = 'access-to-admin-page-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it('/admin is successfully loaded', () => {
-    cy.visit('/admin');
-    cy.getByTestid('admin-home').should('be.visible');
-    cy.getByTestid('admin-system-information-table').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin`);
-  });
-
-  it('/admin/app is successfully loaded', () => {
-    cy.visit('/admin/app');
-    cy.getByTestid('admin-app-settings').should('be.visible');
-    cy.getByTestid('v5-page-migration').should('be.visible');
-    cy.get('#cbFileUpload').should('be.checked');
-    cy.get('#isQuestionnaireEnabled').should('be.checked');
-    cy.get('#isAppSiteUrlHashed').should('not.be.checked');
-    cy.screenshot(`${ssPrefix}-admin-app`);
-  });
-
-  it('/admin/security is successfully loaded', () => {
-    cy.visit('/admin/security');
-    cy.getByTestid('admin-security').should('be.visible');
-    cy.get('#isShowRestrictedByOwner').should('be.checked')
-    cy.get('#isShowRestrictedByGroup').should('be.checked')
-    cy.screenshot(`${ssPrefix}-admin-security`);
-  });
-
-  it('/admin/markdown is successfully loaded', () => {
-    cy.visit('/admin/markdown');
-    cy.getByTestid('admin-markdown').should('be.visible');
-    cy.get('#isEnabledLinebreaksInComments').should('be.checked')
-    cy.screenshot(`${ssPrefix}-admin-markdown`);
-  });
-
-  it('/admin/customize is successfully loaded', () => {
-    cy.visit('/admin/customize');
-    cy.getByTestid('admin-customize').should('be.visible');
-    /* eslint-disable cypress/no-unnecessary-waiting */
-    cy.wait(500); // wait for loading layout image
-    cy.screenshot(`${ssPrefix}-admin-customize`);
-  });
-
-  it('/admin/importer is successfully loaded', () => {
-    cy.visit('/admin/importer');
-    cy.getByTestid('admin-import-data').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-importer`);
-  });
-
-  it('/admin/export is successfully loaded', () => {
-    cy.visit('/admin/export');
-    cy.getByTestid('admin-export-archive-data').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-export`);
-  });
-
-  it('/admin/notification is successfully loaded', () => {
-    cy.visit('/admin/notification');
-    cy.getByTestid('admin-notification').should('be.visible');
-    // wait for retrieving slack integration status
-    cy.getByTestid('slack-integration-list-item').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-notification`);
-  });
-
-  it('/admin/slack-integration is successfully loaded', () => {
-    cy.visit('/admin/slack-integration');
-    cy.getByTestid('admin-slack-integration').should('be.visible');
-
-    cy.get('img.bot-difficulty-icon')
-      .should('have.length', 3)
-      .should('be.visible');
-
-    cy.screenshot(`${ssPrefix}-admin-slack-integration`);
-  });
-
-  it('/admin/slack-integration-legacy is successfully loaded', () => {
-    cy.visit('/admin/slack-integration-legacy');
-    cy.getByTestid('admin-slack-integration-legacy').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-slack-integration-legacy`);
-  });
-
-  it('/admin/users is successfully loaded', () => {
-    cy.visit('/admin/users');
-    cy.getByTestid('admin-users').should('be.visible');
-    cy.getByTestid('user-table-tr').first().should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-users`);
-  });
-
-  it('/admin/user-groups is successfully loaded', () => {
-    cy.visit('/admin/user-groups');
-    cy.getByTestid('admin-user-groups').should('be.visible');
-    cy.getByTestid('grw-user-group-table').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-user-groups`);
-  });
-
-  it('/admin/search is successfully loaded', () => {
-    cy.visit('/admin/search');
-    cy.getByTestid('admin-full-text-search').should('be.visible');
-    // wait for connected
-    cy.getByTestid('connection-status-badge-connected').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-search`);
-  });
-
-});

+ 0 - 3
apps/app/test/integration/service/page-grant.test.ts

@@ -26,7 +26,6 @@ describe('PageGrantService', () => {
    */
   let crowi;
   let pageGrantService: IPageGrantService;
-  let xssSpy;
 
   let user1;
   let user2;
@@ -489,8 +488,6 @@ describe('PageGrantService', () => {
 
     await createDocumentsToTestIsGrantNormalized();
     await createDocumentsToTestGetPageGroupGrantData();
-
-    xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
   });
 
   describe('Test isGrantNormalized method with shouldCheckDescendants false', () => {

+ 9 - 8
apps/app/test/integration/service/page.test.js

@@ -8,6 +8,7 @@ import Tag from '~/server/models/tag';
 import UserGroup from '~/server/models/user-group';
 import UserGroupRelation from '~/server/models/user-group-relation';
 
+import { generalXssFilter } from '../../../src/services/general-xss-filter';
 
 const mongoose = require('mongoose');
 
@@ -66,7 +67,7 @@ describe('PageService', () => {
   let Bookmark;
   let Comment;
   let ShareLink;
-  let xssSpy;
+  let generalXssFilterProcessSpy;
 
   beforeAll(async() => {
     crowi = await getInstance();
@@ -346,7 +347,7 @@ describe('PageService', () => {
       },
     ]);
 
-    xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
+    generalXssFilterProcessSpy = jest.spyOn(generalXssFilter, 'process');
 
     /**
      * getParentAndFillAncestors
@@ -494,7 +495,7 @@ describe('PageService', () => {
         const resultPage = await crowi.pageService.renamePage(parentForRename1,
           '/renamed1', testUser2, {}, { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
-        expect(xssSpy).toHaveBeenCalled();
+        expect(generalXssFilterProcessSpy).toHaveBeenCalled();
 
         expect(pageEventSpy).toHaveBeenCalledWith('rename');
 
@@ -508,7 +509,7 @@ describe('PageService', () => {
         const resultPage = await crowi.pageService.renamePage(parentForRename2, '/renamed2', testUser2, { updateMetadata: true },
           { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
-        expect(xssSpy).toHaveBeenCalled();
+        expect(generalXssFilterProcessSpy).toHaveBeenCalled();
 
         expect(pageEventSpy).toHaveBeenCalledWith('rename');
 
@@ -522,7 +523,7 @@ describe('PageService', () => {
         const resultPage = await crowi.pageService.renamePage(parentForRename3, '/renamed3', testUser2, { createRedirectPage: true },
           { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
-        expect(xssSpy).toHaveBeenCalled();
+        expect(generalXssFilterProcessSpy).toHaveBeenCalled();
         expect(pageEventSpy).toHaveBeenCalledWith('rename');
 
         expect(resultPage.path).toBe('/renamed3');
@@ -535,7 +536,7 @@ describe('PageService', () => {
         const resultPage = await crowi.pageService.renamePage(parentForRename4, '/renamed4', testUser2, { isRecursively: true },
           { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
-        expect(xssSpy).toHaveBeenCalled();
+        expect(generalXssFilterProcessSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
         expect(pageEventSpy).toHaveBeenCalledWith('rename');
 
@@ -625,7 +626,7 @@ describe('PageService', () => {
       const resultPage = await crowi.pageService.duplicate(parentForDuplicate, '/newParentDuplicate', testUser2, false);
       const duplicatedToPageRevision = await Revision.findOne({ pageId: resultPage._id });
 
-      expect(xssSpy).toHaveBeenCalled();
+      expect(generalXssFilterProcessSpy).toHaveBeenCalled();
       expect(duplicateDescendantsWithStreamSpy).not.toHaveBeenCalled();
       // TODO https://redmine.weseek.co.jp/issues/87537 : activate outer module mockImplementation
       // expect(serializePageSecurely).toHaveBeenCalled();
@@ -646,7 +647,7 @@ describe('PageService', () => {
       const resultPageRecursivly = await crowi.pageService.duplicate(parentForDuplicate, '/newParentDuplicateRecursively', testUser2, true);
       const duplicatedRecursivelyToPageRevision = await Revision.findOne({ pageId: resultPageRecursivly._id });
 
-      expect(xssSpy).toHaveBeenCalled();
+      expect(generalXssFilterProcessSpy).toHaveBeenCalled();
       expect(duplicateDescendantsWithStreamSpy).toHaveBeenCalled();
       // TODO https://redmine.weseek.co.jp/issues/87537 : activate outer module mockImplementation
       // expect(serializePageSecurely).toHaveBeenCalled();

+ 10 - 9
apps/app/test/integration/service/v5.non-public-page.test.ts

@@ -10,6 +10,7 @@ import PageTagRelation from '../../../src/server/models/page-tag-relation';
 import Tag from '../../../src/server/models/tag';
 import UserGroup from '../../../src/server/models/user-group';
 import UserGroupRelation from '../../../src/server/models/user-group-relation';
+import { generalXssFilter } from '../../../src/services/general-xss-filter';
 import { getInstance } from '../setup-crowi';
 
 describe('PageService page operations with non-public pages', () => {
@@ -31,7 +32,7 @@ describe('PageService page operations with non-public pages', () => {
   let Page;
   let Revision;
   let User;
-  let xssSpy;
+  let generalXssFilterProcessSpy;
 
   let rootPage;
 
@@ -290,7 +291,7 @@ describe('PageService page operations with non-public pages', () => {
       },
     ]);
 
-    xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
+    generalXssFilterProcessSpy = jest.spyOn(generalXssFilter, 'process');
 
     dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
     dummyUser2 = await User.findOne({ username: 'v5DummyUser2' });
@@ -1139,7 +1140,7 @@ describe('PageService page operations with non-public pages', () => {
       expect(page3Renamed.parent).toStrictEqual(page2Renamed._id);
       expect(normalizeGrantedGroups(page2Renamed.grantedGroups)).toStrictEqual(normalizeGrantedGroups(_page2.grantedGroups));
       expect(normalizeGrantedGroups(page3Renamed.grantedGroups)).toStrictEqual(normalizeGrantedGroups(_page3.grantedGroups));
-      expect(xssSpy).toHaveBeenCalled();
+      expect(generalXssFilterProcessSpy).toHaveBeenCalled();
     });
     test('Should throw with NOT grant normalized pages', async() => {
       const _pathD = '/np_rename4_destination';
@@ -1206,7 +1207,7 @@ describe('PageService page operations with non-public pages', () => {
       expect(page2Renamed).toBeTruthy();
       expect(page3Renamed).toBeNull();
       expect(page2Renamed.parent).toBeNull();
-      expect(xssSpy).toHaveBeenCalled();
+      expect(generalXssFilterProcessSpy).toHaveBeenCalled();
     });
   });
   describe('Duplicate', () => {
@@ -1240,7 +1241,7 @@ describe('PageService page operations with non-public pages', () => {
 
       const duplicatedPage = await Page.findOne({ path: newPagePath });
       const duplicatedRevision = await Revision.findOne({ pageId: duplicatedPage._id });
-      expect(xssSpy).toHaveBeenCalled();
+      expect(generalXssFilterProcessSpy).toHaveBeenCalled();
       expect(duplicatedPage).toBeTruthy();
       expect(duplicatedPage._id).not.toStrictEqual(_page._id);
       expect(duplicatedPage.grant).toBe(_page.grant);
@@ -1271,7 +1272,7 @@ describe('PageService page operations with non-public pages', () => {
       const duplicatedPage2 = await Page.findOne({ path: '/dup_np_duplicate2/np_duplicate3' }).populate({ path: 'revision', model: 'Revision' });
       const duplicatedRevision1 = duplicatedPage1.revision;
       const duplicatedRevision2 = duplicatedPage2.revision;
-      expect(xssSpy).toHaveBeenCalled();
+      expect(generalXssFilterProcessSpy).toHaveBeenCalled();
       expect(duplicatedPage1).toBeTruthy();
       expect(duplicatedPage2).toBeTruthy();
       expect(duplicatedRevision1).toBeTruthy();
@@ -1316,7 +1317,7 @@ describe('PageService page operations with non-public pages', () => {
       const duplicatedPage3 = await Page.findOne({ path: '/dup_np_duplicate4/np_duplicate6' }).populate({ path: 'revision', model: 'Revision' });
       const duplicatedRevision1 = duplicatedPage1.revision;
       const duplicatedRevision3 = duplicatedPage3.revision;
-      expect(xssSpy).toHaveBeenCalled();
+      expect(generalXssFilterProcessSpy).toHaveBeenCalled();
       expect(duplicatedPage1).toBeTruthy();
       expect(duplicatedPage2).toBeNull();
       expect(duplicatedPage3).toBeTruthy();
@@ -1352,7 +1353,7 @@ describe('PageService page operations with non-public pages', () => {
       const duplicatedPage2 = await Page.findOne({ path: '/dup_np_duplicate7/np_duplicate8' }).populate({ path: 'revision', model: 'Revision' });
       const duplicatedPage3 = await Page.findOne({ path: '/dup_np_duplicate7/np_duplicate9' }).populate({ path: 'revision', model: 'Revision' });
       const duplicatedRevision1 = duplicatedPage1.revision;
-      expect(xssSpy).toHaveBeenCalled();
+      expect(generalXssFilterProcessSpy).toHaveBeenCalled();
       expect(duplicatedPage1).toBeTruthy();
       expect(duplicatedPage2).toBeFalsy();
       expect(duplicatedPage3).toBeFalsy();
@@ -1394,7 +1395,7 @@ describe('PageService page operations with non-public pages', () => {
       const duplicatedRevision1 = duplicatedPage1.revision;
       const duplicatedRevision2 = duplicatedPage2.revision;
       const duplicatedRevision3 = duplicatedPage3.revision;
-      expect(xssSpy).toHaveBeenCalled();
+      expect(generalXssFilterProcessSpy).toHaveBeenCalled();
       expect(duplicatedPage1).toBeTruthy();
       expect(duplicatedPage2).toBeTruthy();
       expect(duplicatedPage3).toBeTruthy();

+ 0 - 2
apps/app/test/integration/service/v5.page.test.ts

@@ -16,7 +16,6 @@ describe('Test page service methods', () => {
   let ShareLink;
   let PageRedirect;
   let PageOperation;
-  let xssSpy;
 
   let rootPage;
 
@@ -50,7 +49,6 @@ describe('Test page service methods', () => {
     /*
      * Common
      */
-    xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
 
     // ***********************************************************************************************************
     // * Do NOT change properties of globally used documents. Otherwise, it might cause some errors in other tests

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