Răsfoiți Sursa

Merge pull request #8867 from weseek/master

Release v7.0.10
Yuki Takei 1 an în urmă
părinte
comite
e6786cb16a
100 a modificat fișierele cu 1237 adăugiri și 1146 ștergeri
  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

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff