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

Merge branch 'feat/enhanced-access-token' into feat/162852-163262-add-accesstokenparser-for-api-endpoint

reiji-h 1 год назад
Родитель
Сommit
abeec4a9f2
100 измененных файлов с 4141 добавлено и 360 удалено
  1. 8 4
      .devcontainer/app/devcontainer.json
  2. 4 0
      .devcontainer/app/initializeCommand.sh
  3. 5 0
      .devcontainer/app/postCreateCommand.sh
  4. 12 0
      .devcontainer/compose.extend.template.yml
  5. 3 1
      .devcontainer/compose.yml
  6. 30 0
      .devcontainer/pdf-converter/devcontainer.json
  7. 4 0
      .devcontainer/pdf-converter/initializeCommand.sh
  8. 22 0
      .devcontainer/pdf-converter/postCreateCommand.sh
  9. 1 1
      .github/workflows/auto-approve.yml
  10. 1 1
      .github/workflows/auto-labeling.yml
  11. 168 0
      .github/workflows/ci-pdf-converter.yml
  12. 1 1
      .github/workflows/draft-release.yml
  13. 120 0
      .github/workflows/release-pdf-converter.yml
  14. 1 6
      .github/workflows/release-slackbot-proxy.yml
  15. 2 0
      .gitignore
  16. 2 0
      apps/app/bin/swagger-jsdoc/definition-apiv3.js
  17. 1 0
      apps/app/bin/swagger-jsdoc/generate-spec-apiv3.sh
  18. 5 1
      apps/app/docker/docker-entrypoint.sh
  19. 5 0
      apps/app/package.json
  20. 9 2
      apps/app/public/static/locales/en_US/admin.json
  21. 17 2
      apps/app/public/static/locales/en_US/translation.json
  22. 9 2
      apps/app/public/static/locales/fr_FR/admin.json
  23. 17 2
      apps/app/public/static/locales/fr_FR/translation.json
  24. 9 2
      apps/app/public/static/locales/ja_JP/admin.json
  25. 17 2
      apps/app/public/static/locales/ja_JP/translation.json
  26. 9 2
      apps/app/public/static/locales/zh_CN/admin.json
  27. 17 2
      apps/app/public/static/locales/zh_CN/translation.json
  28. 11 0
      apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx
  29. 9 5
      apps/app/src/client/components/Admin/App/FileUploadSetting.tsx
  30. 136 0
      apps/app/src/client/components/Admin/App/PageBulkExportSettings.tsx
  31. 42 28
      apps/app/src/client/components/Admin/App/QuestionnaireSettings.tsx
  32. 26 17
      apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx
  33. 0 0
      apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.module.scss
  34. 13 6
      apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.tsx
  35. 69 0
      apps/app/src/client/components/InAppNotification/ModelNotification/PageBulkExportJobModelNotification.tsx
  36. 2 8
      apps/app/src/client/components/InAppNotification/ModelNotification/PageModelNotification.tsx
  37. 2 1
      apps/app/src/client/components/InAppNotification/ModelNotification/UserModelNotification.tsx
  38. 31 0
      apps/app/src/client/components/InAppNotification/ModelNotification/index.tsx
  39. 9 0
      apps/app/src/client/components/InAppNotification/ModelNotification/useActionAndMsg.ts
  40. 0 19
      apps/app/src/client/components/InAppNotification/PageNotification/index.tsx
  41. 20 22
      apps/app/src/client/components/Me/AccessTokenForm.tsx
  42. 36 0
      apps/app/src/client/components/Me/AccessTokenScopeList.module.scss
  43. 89 0
      apps/app/src/client/components/Me/AccessTokenScopeList.tsx
  44. 42 0
      apps/app/src/client/components/Me/AccessTokenScopeSelect.tsx
  45. 19 3
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  46. 2 2
      apps/app/src/client/components/Navbar/PageEditorModeManager.tsx
  47. 1 1
      apps/app/src/client/components/SearchPage/SearchResultContent.tsx
  48. 6 1
      apps/app/src/client/services/AdminAppContainer.js
  49. 133 0
      apps/app/src/client/util/scope-util.test.ts
  50. 143 0
      apps/app/src/client/util/scope-util.ts
  51. 3 0
      apps/app/src/components/Layout/BasicLayout.tsx
  52. 498 0
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  53. 3 3
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx
  54. 1 1
      apps/app/src/features/openai/interfaces/ai-assistant.ts
  55. 25 9
      apps/app/src/features/openai/server/models/ai-assistant.ts
  56. 1 1
      apps/app/src/features/openai/server/routes/ai-assistant.ts
  57. 2 4
      apps/app/src/features/openai/server/routes/update-ai-assistant.ts
  58. 16 11
      apps/app/src/features/openai/server/services/openai.ts
  59. 119 0
      apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx
  60. 27 0
      apps/app/src/features/page-bulk-export/client/stores/modal.tsx
  61. 49 0
      apps/app/src/features/page-bulk-export/interfaces/page-bulk-export.ts
  62. 29 0
      apps/app/src/features/page-bulk-export/server/models/page-bulk-export-job.ts
  63. 19 0
      apps/app/src/features/page-bulk-export/server/models/page-bulk-export-page-snapshot.ts
  64. 58 0
      apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts
  65. 42 0
      apps/app/src/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron.ts
  66. 180 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.integ.ts
  67. 118 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.ts
  68. 15 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/errors.ts
  69. 285 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts
  70. 62 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts
  71. 70 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts
  72. 103 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/create-page-snapshots-async.ts
  73. 112 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/export-pages-to-fs-async.ts
  74. 78 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export.ts
  75. 28 28
      apps/app/src/features/questionnaire/server/service/questionnaire-cron.integ.ts
  76. 16 42
      apps/app/src/features/questionnaire/server/service/questionnaire-cron.ts
  77. 14 0
      apps/app/src/interfaces/activity.ts
  78. 28 0
      apps/app/src/interfaces/file-uploader.ts
  79. 5 0
      apps/app/src/interfaces/res/admin/app-settings.ts
  80. 12 0
      apps/app/src/migrations/19700101000000-foremost-1000-20241123211930-remove-index-for-ns-from-configs.js
  81. 25 0
      apps/app/src/models/serializers/in-app-notification-snapshot/page-bulk-export-job.ts
  82. 9 1
      apps/app/src/pages/[[...path]].page.tsx
  83. 17 14
      apps/app/src/server/crowi/index.js
  84. 7 0
      apps/app/src/server/interfaces/attachment.ts
  85. 2 1
      apps/app/src/server/models/activity.ts
  86. 6 2
      apps/app/src/server/models/attachment.ts
  87. 8 4
      apps/app/src/server/models/subscription.ts
  88. 0 13
      apps/app/src/server/models/vo/collection-progress.js
  89. 19 0
      apps/app/src/server/models/vo/collection-progress.ts
  90. 12 7
      apps/app/src/server/models/vo/collection-progressing-status.ts
  91. 3 5
      apps/app/src/server/routes/admin.js
  92. 38 1
      apps/app/src/server/routes/apiv3/app-settings.js
  93. 2 1
      apps/app/src/server/routes/apiv3/export.js
  94. 5 4
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  95. 1 0
      apps/app/src/server/routes/apiv3/index.js
  96. 5 1
      apps/app/src/server/routes/apiv3/page/index.ts
  97. 89 19
      apps/app/src/server/routes/apiv3/personal-setting/index.js
  98. 171 9
      apps/app/src/server/routes/apiv3/security-settings/index.js
  99. 181 11
      apps/app/src/server/routes/apiv3/slack-integration-settings.js
  100. 183 24
      apps/app/src/server/routes/apiv3/user-group.js

+ 8 - 4
.devcontainer/devcontainer.json → .devcontainer/app/devcontainer.json

@@ -2,8 +2,8 @@
 // README at: https://github.com/devcontainers/templates/tree/main/src/ubuntu
 // README at: https://github.com/devcontainers/templates/tree/main/src/ubuntu
 {
 {
   "name": "GROWI-Dev",
   "name": "GROWI-Dev",
-  "dockerComposeFile": "compose.yml",
-  "service": "node",
+  "dockerComposeFile": ["../compose.yml", "../compose.extend.yml"],
+  "service": "app",
   "workspaceFolder": "/workspace/growi",
   "workspaceFolder": "/workspace/growi",
 
 
   "features": {
   "features": {
@@ -15,8 +15,9 @@
   // Use 'forwardPorts' to make a list of ports inside the container available locally.
   // Use 'forwardPorts' to make a list of ports inside the container available locally.
   // "forwardPorts": [],
   // "forwardPorts": [],
 
 
+  "initializeCommand": "/bin/bash .devcontainer/pdf-converter/initializeCommand.sh",
   // Use 'postCreateCommand' to run commands after the container is created.
   // Use 'postCreateCommand' to run commands after the container is created.
-  "postCreateCommand": "/bin/bash ./.devcontainer/postCreateCommand.sh",
+  "postCreateCommand": "/bin/bash ./.devcontainer/app/postCreateCommand.sh",
 
 
   // Configure tool-specific properties.
   // Configure tool-specific properties.
   "customizations": {
   "customizations": {
@@ -37,7 +38,10 @@
         "vitest.explorer",
         "vitest.explorer",
         "ms-playwright.playwright"
         "ms-playwright.playwright"
       ],
       ],
-    }
+      "settings": {
+        "terminal.integrated.defaultProfile.linux": "bash"
+      }
+    },
   },
   },
 
 
   // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
   // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.

+ 4 - 0
.devcontainer/app/initializeCommand.sh

@@ -0,0 +1,4 @@
+# prevent file not found error on docker compose up
+if [ ! -f ".devcontainer/compose.extend.yml" ]; then
+  touch .devcontainer/compose.extend.yml
+fi

+ 5 - 0
.devcontainer/postCreateCommand.sh → .devcontainer/app/postCreateCommand.sh

@@ -6,6 +6,11 @@ sudo apt-get install -y --no-install-recommends \
   iputils-ping net-tools dnsutils
   iputils-ping net-tools dnsutils
 sudo apt-get clean -y
 sudo apt-get clean -y
 
 
+# Set permissions for shared directory for bulk export
+mkdir -p /tmp/page-bulk-export
+sudo chown -R vscode:vscode /tmp/page-bulk-export
+sudo chmod 700 /tmp/page-bulk-export
+
 # Setup pnpm
 # Setup pnpm
 SHELL=bash pnpm setup
 SHELL=bash pnpm setup
 eval "$(cat /home/vscode/.bashrc)"
 eval "$(cat /home/vscode/.bashrc)"

+ 12 - 0
.devcontainer/compose.extend.template.yml

@@ -0,0 +1,12 @@
+# A template of the file for extending the primary docker compose configuration.
+# To actually use this file, create a `compose.extend.yml` file and copy the contents of this file into it.
+services:
+  pdf-converter:
+    # enabling devcontainer 'features' was not working for secondary devcontainer (https://github.com/devcontainers/features/issues/1175)
+    image: mcr.microsoft.com/vscode/devcontainers/javascript-node:1-20
+    volumes:
+      - ..:/workspace/growi:delegated
+      - pnpm-store:/workspace/growi/.pnpm-store
+      - node_modules:/workspace/growi/node_modules
+      - page_bulk_export_tmp:/tmp/page-bulk-export
+    tty: true

+ 3 - 1
.devcontainer/compose.yml

@@ -1,5 +1,5 @@
 services:
 services:
-  node:
+  app:
     image: mcr.microsoft.com/devcontainers/base:ubuntu
     image: mcr.microsoft.com/devcontainers/base:ubuntu
     volumes:
     volumes:
       - ..:/workspace/growi:delegated
       - ..:/workspace/growi:delegated
@@ -8,6 +8,7 @@ services:
       - buildcache_app:/workspace/growi/apps/app/.next
       - buildcache_app:/workspace/growi/apps/app/.next
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
       - ../../share:/workspace/share:delegated
       - ../../share:/workspace/share:delegated
+      - page_bulk_export_tmp:/tmp/page-bulk-export
     tty: true
     tty: true
     networks:
     networks:
     - default
     - default
@@ -48,6 +49,7 @@ volumes:
   pnpm-store:
   pnpm-store:
   node_modules:
   node_modules:
   buildcache_app:
   buildcache_app:
+  page_bulk_export_tmp:
 
 
 networks:
 networks:
   default:
   default:

+ 30 - 0
.devcontainer/pdf-converter/devcontainer.json

@@ -0,0 +1,30 @@
+{
+  "name": "GROWI-PDF-Converter",
+  "dockerComposeFile": ["../compose.yml", "../compose.extend.yml"],
+  "service": "pdf-converter",
+  "workspaceFolder": "/workspace/growi",
+
+  // Use 'forwardPorts' to make a list of ports inside the container available locally.
+  // "forwardPorts": [],
+
+  "initializeCommand": "/bin/bash .devcontainer/pdf-converter/initializeCommand.sh",
+  // Use 'postCreateCommand' to run commands after the container is created.
+  "postCreateCommand": "/bin/bash ./.devcontainer/pdf-converter/postCreateCommand.sh",
+
+  // Configure tool-specific properties.
+  "customizations": {
+    "vscode": {
+      "extensions": [
+        "dbaeumer.vscode-eslint",
+        "mhutchie.git-graph",
+        "eamodio.gitlens"
+      ],
+      "settings": {
+        "terminal.integrated.defaultProfile.linux": "bash"
+      }
+    }
+  }
+
+  // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
+  // "remoteUser": "root"
+}

+ 4 - 0
.devcontainer/pdf-converter/initializeCommand.sh

@@ -0,0 +1,4 @@
+# prevent file not found error on docker compose up
+if [ ! -f ".devcontainer/compose.extend.yml" ]; then
+  touch .devcontainer/compose.extend.yml
+fi

+ 22 - 0
.devcontainer/pdf-converter/postCreateCommand.sh

@@ -0,0 +1,22 @@
+# Instal additional packages
+sudo apt update
+sudo apt-get install -y --no-install-recommends \
+  chromium fonts-lato fonts-ipafont-gothic fonts-noto-cjk
+sudo apt-get clean -y
+
+# Set permissions for shared directory for bulk export
+mkdir -p /tmp/page-bulk-export
+sudo chown -R node:node /tmp/page-bulk-export
+sudo chmod 700 /tmp/page-bulk-export
+
+# Setup pnpm
+SHELL=bash pnpm setup
+eval "$(cat /home/node/.bashrc)"
+# Update pnpm
+pnpm i -g pnpm
+
+# Install turbo
+pnpm install turbo --global
+
+# Install dependencies
+turbo run bootstrap

+ 1 - 1
.github/workflows/auto-approve.yml

@@ -16,7 +16,7 @@ jobs:
     steps:
     steps:
       - name: Dependabot metadata
       - name: Dependabot metadata
         id: dependabot-metadata
         id: dependabot-metadata
-        uses: dependabot/fetch-metadata@v1
+        uses: dependabot/fetch-metadata@v2
         with:
         with:
           github-token: '${{ secrets.GITHUB_TOKEN }}'
           github-token: '${{ secrets.GITHUB_TOKEN }}'
       - name: Approve a PR
       - name: Approve a PR

+ 1 - 1
.github/workflows/auto-labeling.yml

@@ -25,7 +25,7 @@ jobs:
         && !startsWith( github.head_ref, 'mergify/merge-queue/' ))
         && !startsWith( github.head_ref, 'mergify/merge-queue/' ))
 
 
     steps:
     steps:
-      - uses: release-drafter/release-drafter@v5
+      - uses: release-drafter/release-drafter@v6
         with:
         with:
           disable-releaser: true
           disable-releaser: true
         env:
         env:

+ 168 - 0
.github/workflows/ci-pdf-converter.yml

@@ -0,0 +1,168 @@
+name: Node CI for pdf-converter
+
+on:
+  push:
+    branches-ignore:
+      - release/**
+      - rc/**
+      - support/prepare-v**
+    paths:
+      - .github/mergify.yml
+      - .github/workflows/ci-pdf-converter.yml
+      - .eslint*
+      - tsconfig.base.json
+      - turbo.json
+      - pnpm-lock.yaml
+      - package.json
+      - apps/pdf-converter/**
+      - '!apps/pdf-converter/docker/**'
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+
+jobs:
+
+  ci-pdf-converter-lint:
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        node-version: [20.x]
+
+    steps:
+    - uses: actions/checkout@v4
+
+    - uses: pnpm/action-setup@v4
+
+    - uses: actions/setup-node@v4
+      with:
+        node-version: ${{ matrix.node-version }}
+        cache: 'pnpm'
+
+    - name: Install dependencies
+      run: |
+        pnpm add turbo --global
+        pnpm install --frozen-lockfile
+
+    - name: Lint
+      run: |
+        turbo run lint --filter=@growi/pdf-converter
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi-pdf-converter - test (${{ matrix.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+  ci-pdf-converter-launch-dev:
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        node-version: [20.x]
+
+    steps:
+    - uses: actions/checkout@v4
+
+    - uses: pnpm/action-setup@v4
+
+    - uses: actions/setup-node@v4
+      with:
+        node-version: ${{ matrix.node-version }}
+        cache: 'pnpm'
+
+    - name: Install dependencies
+      run: |
+        pnpm add turbo --global
+        pnpm install --frozen-lockfile
+
+    - name: turbo run dev:pdf-converter:ci
+      working-directory: ./apps/pdf-converter
+      run: turbo run dev:pdf-converter:ci
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi-pdf-converter - launch-dev (${{ matrix.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+  ci-pdf-converter-launch-prod:
+
+    if: startsWith(github.head_ref, 'mergify/merge-queue/')
+
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        node-version: [20.x]
+
+    steps:
+    - uses: actions/checkout@v4
+
+    - uses: pnpm/action-setup@v4
+
+    - uses: actions/setup-node@v4
+      with:
+        node-version: ${{ matrix.node-version }}
+        cache: 'pnpm'
+
+    - name: Install turbo
+      run: |
+        pnpm add turbo --global
+
+    - name: Install dependencies
+      run: |
+        pnpm install --frozen-lockfile
+
+    - name: Restore dist
+      uses: actions/cache/restore@v4
+      with:
+        path: |
+          **/.turbo
+          **/dist
+        key: dist-pdf-converter-prod-${{ runner.OS }}-node${{ matrix.node-version }}-${{ github.sha }}
+        restore-keys: |
+          dist-pdf-converter-prod-${{ runner.OS }}-node${{ matrix.node-version }}-
+
+    - name: Build
+      working-directory: ./apps/pdf-converter
+      run: |
+        turbo run build
+
+    - name: Assembling all dependencies
+      run: |
+        rm -rf out
+        pnpm deploy out --prod --filter @growi/pdf-converter
+        rm -rf apps/pdf-converter/node_modules && mv out/node_modules apps/pdf-converter/node_modules
+
+    - name: pnpm run start:prod:ci
+      working-directory: ./apps/pdf-converter
+      run: pnpm run start:prod:ci
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi-pdf-converter - launch-prod (${{ matrix.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+    - name: Cache dist
+      uses: actions/cache/save@v4
+      with:
+        path: |
+          **/.turbo
+          **/dist
+        key: dist-pdf-converter-prod-${{ runner.OS }}-node${{ matrix.node-version }}-${{ github.sha }}

+ 1 - 1
.github/workflows/draft-release.yml

@@ -29,7 +29,7 @@ jobs:
         uses: myrotvorets/info-from-package-json-action@v2.0.2
         uses: myrotvorets/info-from-package-json-action@v2.0.2
         id: package-json
         id: package-json
 
 
-      - uses: release-drafter/release-drafter@v5
+      - uses: release-drafter/release-drafter@v6
         id: release-drafter
         id: release-drafter
         with:
         with:
           config-name: release-drafter.yml
           config-name: release-drafter.yml

+ 120 - 0
.github/workflows/release-pdf-converter.yml

@@ -0,0 +1,120 @@
+name: Release Docker Image for @growi/pdf-converter
+
+on:
+  pull_request:
+    branches:
+      - release/pdf-converter/**
+    types: [closed]
+
+jobs:
+  build-and-push-image:
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v4
+      with:
+        ref: ${{ github.event.pull_request.base.ref }}
+
+    - name: Retrieve information from package.json
+      uses: myrotvorets/info-from-package-json-action@2.0.1
+      id: package-json
+      with:
+        workingDir: apps/pdf-converter
+
+    - name: Docker meta
+      id: meta
+      uses: docker/metadata-action@v4
+      with:
+        images: growilabs/pdf-converter
+        tags: |
+          type=raw,value=latest
+          type=raw,value=${{ steps.package-json.outputs.packageVersion }}
+
+    - name: Login to docker.io registry
+      run: |
+        echo ${{ secrets.DOCKER_REGISTRY_PASSWORD_GROWIMOOGLE }} | docker login --username growimoogle --password-stdin
+
+    - name: Set up Docker Buildx
+      uses: docker/setup-buildx-action@v3
+
+    - name: Build and push
+      uses: docker/build-push-action@v6
+      with:
+        context: .
+        file: ./apps/pdf-converter/docker/Dockerfile
+        platforms: linux/amd64,linux/arm64
+        push: true
+        builder: ${{ steps.buildx.outputs.name }}
+        cache-from: type=gha
+        cache-to: type=gha,mode=max
+        tags: ${{ steps.meta.outputs.tags }}
+
+    - name: Add tag
+      uses: anothrNick/github-tag-action@v1
+      env:
+        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        CUSTOM_TAG: pdf-converter/v${{ steps.package-json.outputs.packageVersion }}
+        VERBOSE : true
+
+    - name: Update Docker Hub Description
+      uses: peter-evans/dockerhub-description@v3
+      with:
+        username: growimoogle
+        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD_GROWIMOOGLE }}
+        repository: growilabs/pdf-converter
+        readme-filepath: ./apps/pdf-converter/docker/README.md
+
+
+  create-pr-for-next-rc:
+    needs: build-and-push-image
+
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        node-version: [20.x]
+
+    steps:
+    - uses: actions/checkout@v4
+      with:
+        ref: ${{ github.event.pull_request.base.ref }}
+
+    - uses: pnpm/action-setup@v4
+
+    - uses: actions/setup-node@v4
+      with:
+        node-version: ${{ matrix.node-version }}
+        cache: 'pnpm'
+
+    - name: Install dependencies
+      run: |
+        pnpm add turbo --global
+        pnpm install --frozen-lockfile
+
+    - name: Bump versions for next RC
+      run: |
+        turbo run version:prerelease --filter=@growi/pdf-converter
+
+    - name: Retrieve information from package.json
+      uses: myrotvorets/info-from-package-json-action@2.0.1
+      id: package-json
+      with:
+        workingDir: apps/pdf-converter
+
+    - name: Commit
+      uses: github-actions-x/commit@v2.9
+      with:
+        github-token: ${{ secrets.GITHUB_TOKEN }}
+        push-branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
+        commit-message: 'Bump version'
+        name: GitHub Action
+
+    - name: Create PR
+      uses: repo-sync/pull-request@v2
+      with:
+        source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
+        destination_branch: master
+        pr_title: Prepare pdf-converter v${{ steps.package-json.outputs.packageVersion }}
+        pr_label: flag/exclude-from-changelog,type/prepare-next-version
+        pr_body: "An automated PR generated by ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+        github_token: ${{ secrets.GITHUB_TOKEN }}

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

@@ -51,7 +51,7 @@ jobs:
       uses: docker/setup-buildx-action@v3
       uses: docker/setup-buildx-action@v3
 
 
     - name: Build and push
     - name: Build and push
-      uses: docker/build-push-action@v4
+      uses: docker/build-push-action@v6
       with:
       with:
         context: .
         context: .
         file: ./apps/slackbot-proxy/docker/Dockerfile
         file: ./apps/slackbot-proxy/docker/Dockerfile
@@ -62,11 +62,6 @@ jobs:
         cache-to: type=gha,mode=max
         cache-to: type=gha,mode=max
         tags: ${{ steps.meta.outputs.tags }}
         tags: ${{ steps.meta.outputs.tags }}
 
 
-    - name: Move cache
-      run: |
-        rm -rf /tmp/.buildx-cache
-        mv /tmp/.buildx-cache-new /tmp/.buildx-cache
-
     - name: Add tag
     - name: Add tag
       uses: anothrNick/github-tag-action@v1
       uses: anothrNick/github-tag-action@v1
       env:
       env:

+ 2 - 0
.gitignore

@@ -44,3 +44,5 @@ yarn-error.log*
 
 
 # pnpm deploy target dir
 # pnpm deploy target dir
 out
 out
+
+.devcontainer/compose.extend.yml

+ 2 - 0
apps/app/bin/swagger-jsdoc/definition-apiv3.js

@@ -65,6 +65,7 @@ module.exports = {
         'Home',
         'Home',
         'AdminHome',
         'AdminHome',
         'AppSettings',
         'AppSettings',
+        'ExternalUserGroups',
         'SecuritySetting',
         'SecuritySetting',
         'MarkDownSetting',
         'MarkDownSetting',
         'CustomizeSetting',
         'CustomizeSetting',
@@ -72,6 +73,7 @@ module.exports = {
         'Export',
         'Export',
         'MongoDB',
         'MongoDB',
         'NotificationSetting',
         'NotificationSetting',
+        'QuestionnaireSetting',
         'SlackIntegrationSettings',
         'SlackIntegrationSettings',
         'SlackIntegrationSettings (with proxy)',
         'SlackIntegrationSettings (with proxy)',
         'SlackIntegrationSettings (without proxy)',
         'SlackIntegrationSettings (without proxy)',

+ 1 - 0
apps/app/bin/swagger-jsdoc/generate-spec-apiv3.sh

@@ -10,5 +10,6 @@ OUT=${OUT:-"${APP_PATH}/tmp/openapi-spec-apiv3.json"}
 swagger-jsdoc \
 swagger-jsdoc \
   -o "${OUT}" \
   -o "${OUT}" \
   -d "${APP_PATH}/bin/swagger-jsdoc/definition-apiv3.js" \
   -d "${APP_PATH}/bin/swagger-jsdoc/definition-apiv3.js" \
+  "${APP_PATH}/src/features/external-user-group/server/routes/apiv3/*.ts" \
   "${APP_PATH}/src/server/routes/apiv3/**/*.{js,ts}" \
   "${APP_PATH}/src/server/routes/apiv3/**/*.{js,ts}" \
   "${APP_PATH}/src/server/models/openapi/**/*.{js,ts}"
   "${APP_PATH}/src/server/models/openapi/**/*.{js,ts}"

+ 5 - 1
apps/app/docker/docker-entrypoint.sh

@@ -7,8 +7,12 @@ mkdir -p /data/uploads
 if [ ! -e "./public/uploads" ]; then
 if [ ! -e "./public/uploads" ]; then
   ln -s /data/uploads ./public/uploads
   ln -s /data/uploads ./public/uploads
 fi
 fi
-
 chown -R node:node /data/uploads
 chown -R node:node /data/uploads
 chown -h node:node ./public/uploads
 chown -h node:node ./public/uploads
 
 
+# Set permissions for shared directory for bulk export
+mkdir -p /tmp/page-bulk-export
+chown -R node:node /tmp/page-bulk-export
+chmod 700 /tmp/page-bulk-export
+
 exec gosu node /bin/bash -c "$@"
 exec gosu node /bin/bash -c "$@"

+ 5 - 0
apps/app/package.json

@@ -82,6 +82,7 @@
     "@growi/remark-growi-directive": "workspace:^",
     "@growi/remark-growi-directive": "workspace:^",
     "@growi/remark-lsx": "workspace:^",
     "@growi/remark-lsx": "workspace:^",
     "@growi/slack": "workspace:^",
     "@growi/slack": "workspace:^",
+    "@growi/pdf-converter-client": "workspace:^",
     "@keycloak/keycloak-admin-client": "^18.0.0",
     "@keycloak/keycloak-admin-client": "^18.0.0",
     "@opentelemetry/api": "^1.9.0",
     "@opentelemetry/api": "^1.9.0",
     "@opentelemetry/auto-instrumentations-node": "^0.55.1",
     "@opentelemetry/auto-instrumentations-node": "^0.55.1",
@@ -94,12 +95,14 @@
     "@opentelemetry/sdk-trace-node": "^1.28.0",
     "@opentelemetry/sdk-trace-node": "^1.28.0",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@slack/webhook": "^6.0.0",
+    "@types/async": "^3.2.24",
     "JSONStream": "^1.3.5",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
     "async-canvas-to-blob": "^1.0.3",
     "axios": "^0.24.0",
     "axios": "^0.24.0",
     "axios-retry": "^3.2.4",
     "axios-retry": "^3.2.4",
+    "babel-plugin-superjson-next": "^0.4.2",
     "body-parser": "^1.20.3",
     "body-parser": "^1.20.3",
     "browser-bunyan": "^1.8.0",
     "browser-bunyan": "^1.8.0",
     "bson-objectid": "^2.0.4",
     "bson-objectid": "^2.0.4",
@@ -214,6 +217,7 @@
     "remark-directive": "^3.0.0",
     "remark-directive": "^3.0.0",
     "remark-frontmatter": "^5.0.0",
     "remark-frontmatter": "^5.0.0",
     "remark-gfm": "^4.0.0",
     "remark-gfm": "^4.0.0",
+    "remark-html": "^16.0.1",
     "remark-math": "^6.0.0",
     "remark-math": "^6.0.0",
     "remark-parse": "^11.0.0",
     "remark-parse": "^11.0.0",
     "remark-rehype": "^11.1.1",
     "remark-rehype": "^11.1.1",
@@ -265,6 +269,7 @@
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/react": "^16.0.1",
     "@testing-library/react": "^16.0.1",
     "@testing-library/user-event": "^14.5.2",
     "@testing-library/user-event": "^14.5.2",
+    "@types/archiver": "^6.0.2",
     "@types/bunyan": "^1.8.11",
     "@types/bunyan": "^1.8.11",
     "@types/express": "^4.17.21",
     "@types/express": "^4.17.21",
     "@types/hast": "^3.0.4",
     "@types/hast": "^3.0.4",

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

@@ -10,6 +10,7 @@
   "only_me": "Only me",
   "only_me": "Only me",
   "only_inside_the_group": "Only inside the group",
   "only_inside_the_group": "Only inside the group",
   "optional": "Optional",
   "optional": "Optional",
+  "days": "days",
   "security_settings": {
   "security_settings": {
     "security_settings": "Security Settings",
     "security_settings": "Security Settings",
     "scope_of_page_disclosure": "Scope of page disclosure",
     "scope_of_page_disclosure": "Scope of page disclosure",
@@ -359,6 +360,11 @@
     "file_uploading": "File uploading",
     "file_uploading": "File uploading",
     "enable_files_except_image": "Enabling this option will allow upload of any file type. Without this option, only image file upload is supported.",
     "enable_files_except_image": "Enabling this option will allow upload of any file type. Without this option, only image file upload is supported.",
     "attach_enable": "You can attach files other than image files if you enable this option.",
     "attach_enable": "You can attach files other than image files if you enable this option.",
+    "page_bulk_export_settings": "Page Bulk Export Settings",
+    "enable_page_bulk_export": "Enable bulk export",
+    "page_bulk_export_explanation": "Enables a feature that allows all users to export a page and all it's child pages at once from the menu. Exported data will be automatically deleted after the storage period has passed.",
+    "page_bulk_export_warning": "The bulk page export feature is available to all users. In order to maintain system resources, we ask for your cooperation in using the minimum amount necessary. If you are an administrator, please inform all users of this.",
+    "page_bulk_export_storage_period": "Storage period",
     "update": "Update",
     "update": "Update",
     "mail_settings": "E-mail Settings",
     "mail_settings": "E-mail Settings",
     "mailer_is_not_set_up": "E-mail setting is not set up.",
     "mailer_is_not_set_up": "E-mail setting is not set up.",
@@ -382,7 +388,7 @@
     "file_delivery_method_relay":"Internal System Relay",
     "file_delivery_method_relay":"Internal System Relay",
     "file_delivery_method_redirect_info":"Redirect: It redirects to a signed URL without GROWI server, it gives excellent performance.",
     "file_delivery_method_redirect_info":"Redirect: It redirects to a signed URL without GROWI server, it gives excellent performance.",
     "file_delivery_method_relay_info":"Internal System Relay: The GROWI server delivers to clients, it provides complete security.",
     "file_delivery_method_relay_info":"Internal System Relay: The GROWI server delivers to clients, it provides complete security.",
-    "fixed_by_env_var": "This is fixed by the env var <code>FILE_UPLOAD={{fileUploadType}}</code>.",
+    "fixed_by_env_var": "This is fixed by the env var <code>{{envKey}}={{envVar}}</code>.",
     "gcs_label": "GCP(GCS)",
     "gcs_label": "GCP(GCS)",
     "aws_label": "AWS(S3)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "local_label": "Local",
@@ -1062,7 +1068,8 @@
     "ADMIN_USER_GROUP_ADD_USER": "Add User to User Group",
     "ADMIN_USER_GROUP_ADD_USER": "Add User to User Group",
     "ADMIN_SEARCH_CONNECTION": "Attempting to reconnect to Elasticsearch",
     "ADMIN_SEARCH_CONNECTION": "Attempting to reconnect to Elasticsearch",
     "ADMIN_SEARCH_INDICES_NORMALIZE": "Normalize of Elasticsearch indexes",
     "ADMIN_SEARCH_INDICES_NORMALIZE": "Normalize of Elasticsearch indexes",
-    "ADMIN_SEARCH_INDICES_REBUILD": "Rebuild Elasticsearch indexes"
+    "ADMIN_SEARCH_INDICES_REBUILD": "Rebuild Elasticsearch indexes",
+    "ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE": "Update Page Bulk Export Settings"
   },
   },
   "g2g": {
   "g2g": {
     "transfer_success": "Completed GROWI to GROWI transfer successfully",
     "transfer_success": "Completed GROWI to GROWI transfer successfully",

+ 17 - 2
apps/app/public/static/locales/en_US/translation.json

@@ -777,11 +777,26 @@
     "discription_heading": "Create Account",
     "discription_heading": "Create Account",
     "discription": "Create an your account with the invited email address"
     "discription": "Create an your account with the invited email address"
   },
   },
-  "export_bulk": {
+  "page_export": {
     "failed_to_export": "Failed to export",
     "failed_to_export": "Failed to export",
     "failed_to_count_pages": "Failed to count pages",
     "failed_to_count_pages": "Failed to count pages",
     "export_page_markdown": "Export page as Markdown",
     "export_page_markdown": "Export page as Markdown",
-    "export_page_pdf": "Export page as PDF"
+    "export_page_pdf": "Export page as PDF",
+    "bulk_export": "Export page and all child pages",
+    "bulk_export_download_explanation": "A notification will be sent when the export is complete. To download the exported file, click the notification.",
+    "bulk_export_exec_time_warning": "If the number of pages is large, it may take a while to export",
+    "large_bulk_export_warning": "To conserve system resources, please refrain from exporting a large number of pages consecutively",
+    "markdown": "Markdown",
+    "choose_export_format": "Select export format",
+    "bulk_export_started": "Please wait a moment...",
+    "bulk_export_download_expired": "Download period has expired",
+    "bulk_export_job_expired": "Export process was canceled because it took too long",
+    "export_in_progress": "Export in progress",
+    "export_in_progress_explanation": "Export with the same format is already in progress. Would you like to restart to export the latest page contents?",
+    "export_cancel_warning": "The following export in progress will be canceled",
+    "restart": "Restart",
+    "format": "Format",
+    "started_on": "Started on"
   },
   },
   "message": {
   "message": {
     "successfully_connected": "Successfully Connected!",
     "successfully_connected": "Successfully Connected!",

+ 9 - 2
apps/app/public/static/locales/fr_FR/admin.json

@@ -10,6 +10,7 @@
   "only_me": "Seulement moi",
   "only_me": "Seulement moi",
   "only_inside_the_group": "Utilisateurs du groupe",
   "only_inside_the_group": "Utilisateurs du groupe",
   "optional": "Optionnel",
   "optional": "Optionnel",
+  "days": "jours",
   "security_settings": {
   "security_settings": {
     "security_settings": "Sécurité",
     "security_settings": "Sécurité",
     "scope_of_page_disclosure": "Confidentialité de la page",
     "scope_of_page_disclosure": "Confidentialité de la page",
@@ -359,6 +360,11 @@
     "file_uploading": "Téléversement de fichiers",
     "file_uploading": "Téléversement de fichiers",
     "enable_files_except_image": "Autoriser tout les types de fichiers",
     "enable_files_except_image": "Autoriser tout les types de fichiers",
     "attach_enable": "Autorise le téléversement de tout les types de fichiers.",
     "attach_enable": "Autorise le téléversement de tout les types de fichiers.",
+    "page_bulk_export_settings": "Paramètres d'exportation de pages par lots",
+    "enable_page_bulk_export": "Activer l'exportation groupée",
+    "page_bulk_export_explanation": "Active une fonctionnalité qui permet à tous les utilisateurs d'exporter simultanément toutes les pages sélectionnées dans le menu des pages et leurs pages subordonnées. Les données exportées seront automatiquement supprimées une fois la période de conservation écoulée.",
+    "page_bulk_export_warning": "La fonctionnalité d’exportation de pages en masse est disponible pour tous les utilisateurs. Afin de maintenir les ressources du système, nous demandons votre coopération pour utiliser le montant minimum nécessaire. Si vous êtes administrateur, veuillez en informer tous les utilisateurs.",
+    "page_bulk_export_storage_period": "Date limite de téléchargement",
     "update": "Sauvegarder",
     "update": "Sauvegarder",
     "mail_settings": "SMTP",
     "mail_settings": "SMTP",
     "mailer_is_not_set_up": "Paramètres d'envoi de courriels non configurés.",
     "mailer_is_not_set_up": "Paramètres d'envoi de courriels non configurés.",
@@ -382,7 +388,7 @@
     "file_delivery_method_relay": "Relai interne du système",
     "file_delivery_method_relay": "Relai interne du système",
     "file_delivery_method_redirect_info": "Rediriger: Redirige vers une URL signé, performance excellente.",
     "file_delivery_method_redirect_info": "Rediriger: Redirige vers une URL signé, performance excellente.",
     "file_delivery_method_relay_info": "Relai interne du système: Le serveur GROWI sert les fichiers directement au client, sécurité complète.",
     "file_delivery_method_relay_info": "Relai interne du système: Le serveur GROWI sert les fichiers directement au client, sécurité complète.",
-    "fixed_by_env_var": "Défini par une variable d'environnement <code>FILE_UPLOAD={{fileUploadType}}</code>.",
+    "fixed_by_env_var": "Défini par une variable d'environnement <code>{{envKey}}={{envVar}}</code>.",
     "gcs_label": "GCP(GCS)",
     "gcs_label": "GCP(GCS)",
     "aws_label": "AWS(S3)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "local_label": "Local",
@@ -1061,7 +1067,8 @@
     "ADMIN_USER_GROUP_ADD_USER": "Ajouter l'utilisateur au groupe",
     "ADMIN_USER_GROUP_ADD_USER": "Ajouter l'utilisateur au groupe",
     "ADMIN_SEARCH_CONNECTION": "Essai de reconnexion Elasticsearch",
     "ADMIN_SEARCH_CONNECTION": "Essai de reconnexion Elasticsearch",
     "ADMIN_SEARCH_INDICES_NORMALIZE": "Nomarliser l'index Elasticsearch",
     "ADMIN_SEARCH_INDICES_NORMALIZE": "Nomarliser l'index Elasticsearch",
-    "ADMIN_SEARCH_INDICES_REBUILD": "Reconstruire l'index Elasticsearch"
+    "ADMIN_SEARCH_INDICES_REBUILD": "Reconstruire l'index Elasticsearch",
+    "ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE": "Mettre à jour les paramètres d'exportation groupée de la page"
   },
   },
   "g2g": {
   "g2g": {
     "transfer_success": "Transfert de GROWI vers GROWI complété!",
     "transfer_success": "Transfert de GROWI vers GROWI complété!",

+ 17 - 2
apps/app/public/static/locales/fr_FR/translation.json

@@ -772,11 +772,26 @@
     "discription_heading": "Créer un compte",
     "discription_heading": "Créer un compte",
     "discription": "Créer un compte avec votre adresse courriel invitée"
     "discription": "Créer un compte avec votre adresse courriel invitée"
   },
   },
-  "export_bulk": {
+  "page_export": {
     "failed_to_export": "Échec de l'export",
     "failed_to_export": "Échec de l'export",
     "failed_to_count_pages": "Échec du compte des pages",
     "failed_to_count_pages": "Échec du compte des pages",
     "export_page_markdown": "Exporter la page en Markdown",
     "export_page_markdown": "Exporter la page en Markdown",
-    "export_page_pdf": "Exporter la page en PDF"
+    "export_page_pdf": "Exporter la page en PDF",
+    "bulk_export": "Exporter la page et toutes les pages enfants",
+    "bulk_export_download_explanation": "Une notification sera envoyée lorsque l’exportation sera terminée. Pour télécharger le fichier exporté, cliquez sur la notification.",
+    "bulk_export_exec_time_warning": "Si le nombre de pages est important, l'exportation peut prendre un certain temps.",
+    "large_bulk_export_warning": "Pour préserver les ressources du système, veuillez éviter d'exporter un grand nombre de pages consécutivement",
+    "markdown": "Markdown",
+    "choose_export_format": "Sélectionnez le format d'exportation",
+    "bulk_export_started": "Patientez s'il-vous-plait...",
+    "bulk_export_download_expired": "La période de téléchargement a expiré",
+    "bulk_export_job_expired": "Le traitement a été interrompu car le temps d'exportation était trop long",
+    "export_in_progress": "Exportation en cours",
+    "export_in_progress_explanation": "L'exportation avec le même format est déjà en cours. Souhaitez-vous redémarrer pour exporter le dernier contenu de la page ?",
+    "export_cancel_warning": "Les exportations suivantes en cours seront annulées",
+    "restart": "Redémarrage",
+    "format": "Format",
+    "started_on": "Commencé le"
   },
   },
   "message": {
   "message": {
     "successfully_connected": "Connecté!",
     "successfully_connected": "Connecté!",

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

@@ -19,6 +19,7 @@
   "only_me": "自分のみ",
   "only_me": "自分のみ",
   "only_inside_the_group": "特定グループのみ",
   "only_inside_the_group": "特定グループのみ",
   "optional": "オプション",
   "optional": "オプション",
+  "days": "日",
   "security_settings": {
   "security_settings": {
     "security_settings": "セキュリティ設定",
     "security_settings": "セキュリティ設定",
     "scope_of_page_disclosure": "ページの公開範囲",
     "scope_of_page_disclosure": "ページの公開範囲",
@@ -368,6 +369,11 @@
     "file_uploading": "ファイルアップロード",
     "file_uploading": "ファイルアップロード",
     "enable_files_except_image": "画像以外のファイルアップロードを許可",
     "enable_files_except_image": "画像以外のファイルアップロードを許可",
     "attach_enable": "許可をしている場合、画像以外のファイルをページに添付可能になります。",
     "attach_enable": "許可をしている場合、画像以外のファイルをページに添付可能になります。",
+    "page_bulk_export_settings": "ページ一括エクスポート設定",
+    "enable_page_bulk_export": "一括エクスポートを有効にする",
+    "page_bulk_export_explanation": "すべてのユーザーが、ページメニューから選択したページとその配下ページをまとめてエクスポートできる機能を有効化します。エクスポートされたデータは保存期間経過後に自動的に削除されます。",
+    "page_bulk_export_warning": "ページ一括エクスポート機能は全ユーザーが利用可能です。システムリソースの維持のため、必要最小限の利用にご協力をお願いいたします。管理者の方は、この旨をユーザーの皆様にご周知ください。",
+    "page_bulk_export_storage_period": "保存期間",
     "update": "更新",
     "update": "更新",
     "mail_settings": "メールの設定",
     "mail_settings": "メールの設定",
     "mailer_is_not_set_up": "メール設定がセットアップされていません。",
     "mailer_is_not_set_up": "メール設定がセットアップされていません。",
@@ -402,7 +408,7 @@
     "azure_storage_account_name": "ストレージアカウント名",
     "azure_storage_account_name": "ストレージアカウント名",
     "azure_storage_container_name": "コンテナ名",
     "azure_storage_container_name": "コンテナ名",
     "azure_note_for_the_only_env_option": "現在Azure設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
     "azure_note_for_the_only_env_option": "現在Azure設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
-    "fixed_by_env_var": "環境変数 <code>FILE_UPLOAD={{fileUploadType}}</code> により固定されています。",
+    "fixed_by_env_var": "環境変数 <code>{{envKey}}={{envVar}}</code> により固定されています。",
     "file_upload": "ファイルをアップロードするための設定を行います。ファイルアップロードの設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "file_upload": "ファイルをアップロードするための設定を行います。ファイルアップロードの設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "test_connection": "接続テスト",
     "test_connection": "接続テスト",
     "change_setting": "この設定を途中で変更すると、これまでにアップロードしたファイル等へのアクセスができなくなりますのでご注意下さい。",
     "change_setting": "この設定を途中で変更すると、これまでにアップロードしたファイル等へのアクセスができなくなりますのでご注意下さい。",
@@ -1071,7 +1077,8 @@
     "ADMIN_USER_GROUP_ADD_USER": "ユーザーグループにユーザーを追加",
     "ADMIN_USER_GROUP_ADD_USER": "ユーザーグループにユーザーを追加",
     "ADMIN_SEARCH_CONNECTION": "Elasticsearch の再接続の試行",
     "ADMIN_SEARCH_CONNECTION": "Elasticsearch の再接続の試行",
     "ADMIN_SEARCH_INDICES_NORMALIZE": "Elasticsearch のインデックスの正規化",
     "ADMIN_SEARCH_INDICES_NORMALIZE": "Elasticsearch のインデックスの正規化",
-    "ADMIN_SEARCH_INDICES_REBUILD": "Elasticsearch のインデックスのリビルド"
+    "ADMIN_SEARCH_INDICES_REBUILD": "Elasticsearch のインデックスのリビルド",
+    "ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE": "ページ一括エクスポート設定の更新"
   },
   },
   "g2g": {
   "g2g": {
     "transfer_success": "G2G移行が完了しました",
     "transfer_success": "G2G移行が完了しました",

+ 17 - 2
apps/app/public/static/locales/ja_JP/translation.json

@@ -810,11 +810,26 @@
     "discription_heading": "アカウント作成",
     "discription_heading": "アカウント作成",
     "discription": "招待を受け取ったメールアドレスでアカウントを作成します"
     "discription": "招待を受け取ったメールアドレスでアカウントを作成します"
   },
   },
-  "export_bulk": {
+  "page_export": {
     "failed_to_export": "ページのエクスポートに失敗しました",
     "failed_to_export": "ページのエクスポートに失敗しました",
     "failed_to_count_pages": "ページ数の取得に失敗しました",
     "failed_to_count_pages": "ページ数の取得に失敗しました",
     "export_page_markdown": "マークダウン形式でページをエクスポート",
     "export_page_markdown": "マークダウン形式でページをエクスポート",
-    "export_page_pdf": "PDF形式でページをエクスポート"
+    "export_page_pdf": "PDF形式でページをエクスポート",
+    "bulk_export": "ページとその配下のページを全てエクスポート",
+    "bulk_export_download_explanation": "エクスポート完了後に通知が届きます。通知をクリックし、ファイルをダウンロードしてください。",
+    "bulk_export_exec_time_warning": "ページ数が多いと、エクスポートに時間がかかる場合があります",
+    "large_bulk_export_warning": "システムリソースの維持のため、ページ数の多いエクスポートを連続して実行することはご遠慮ください",
+    "markdown": "マークダウン",
+    "choose_export_format": "エクスポート形式を選択してください",
+    "bulk_export_started": "ただいま準備中です...",
+    "bulk_export_download_expired": "ダウンロード期限が切れました",
+    "bulk_export_job_expired": "エクスポート時間が長すぎるため、処理が中断されました",
+    "export_in_progress": "エクスポート進行中",
+    "export_in_progress_explanation": "既に同じ形式でのエクスポートが進行中です。最新のページ内容でエクスポートを最初からやり直しますか?",
+    "export_cancel_warning": "進行中の以下のエクスポートはキャンセルされます",
+    "restart": "やり直す",
+    "format": "形式",
+    "started_on": "開始日時"
   },
   },
   "message": {
   "message": {
     "successfully_connected": "接続に成功しました!",
     "successfully_connected": "接続に成功しました!",

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

@@ -19,6 +19,7 @@
   "only_me": "只有我",
   "only_me": "只有我",
   "only_inside_the_group": "仅组内",
   "only_inside_the_group": "仅组内",
   "optional": "可选的",
   "optional": "可选的",
+  "days": "天",
   "security_settings": {
   "security_settings": {
     "security_settings": "安全设置",
     "security_settings": "安全设置",
     "scope_of_page_disclosure": "页面公开范围",
     "scope_of_page_disclosure": "页面公开范围",
@@ -368,6 +369,11 @@
     "file_uploading": "文件上传",
     "file_uploading": "文件上传",
     "enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
     "enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
     "attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
     "attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
+    "page_bulk_export_settings": "页面批量导出设置",
+    "enable_page_bulk_export": "启用批量导出",
+    "page_bulk_export_explanation": "启用一项功能,允许所有用户一次性导出从页面菜单中选择的所有页面及其下级页面。保留期限过后,导出的数据将自动删除。",
+    "page_bulk_export_warning": "批量页面导出功能可供所有用户使用。为了维护系统资源,请您配合使用最低限度的资源。如果您是管理员,请将此事实告知所有用户。",
+    "page_bulk_export_storage_period": "储存期限",
     "update": "更新",
     "update": "更新",
     "mail_settings": "邮件设置",
     "mail_settings": "邮件设置",
     "mailer_is_not_set_up": "邮件设置尚未完成。",
     "mailer_is_not_set_up": "邮件设置尚未完成。",
@@ -402,7 +408,7 @@
     "azure_storage_account_name": "Storage Account Name",
     "azure_storage_account_name": "Storage Account Name",
     "azure_storage_container_name": "Container Name",
     "azure_storage_container_name": "Container Name",
     "azure_note_for_the_only_env_option": "The Azure Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
     "azure_note_for_the_only_env_option": "The Azure Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
-    "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
+    "fixed_by_env_var": "这是由env var 修复的 <code>{{envKey}}={{envVar}}</code>.",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "test_connection": "测试邮件服务器连接",
     "test_connection": "测试邮件服务器连接",
     "change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
     "change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
@@ -1071,7 +1077,8 @@
     "ADMIN_USER_GROUP_ADD_USER": "添加用户到用户组",
     "ADMIN_USER_GROUP_ADD_USER": "添加用户到用户组",
     "ADMIN_SEARCH_CONNECTION": "重试Elasticsearch连接",
     "ADMIN_SEARCH_CONNECTION": "重试Elasticsearch连接",
     "ADMIN_SEARCH_INDICES_NORMALIZE": "试图重新连接Elasticsearch",
     "ADMIN_SEARCH_INDICES_NORMALIZE": "试图重新连接Elasticsearch",
-    "ADMIN_SEARCH_INDICES_REBUILD": "重建 Elasticsearch 索引"
+    "ADMIN_SEARCH_INDICES_REBUILD": "重建 Elasticsearch 索引",
+    "ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE": "更新页面批量导出设置"
   },
   },
   "g2g": {
   "g2g": {
     "transfer_success": "Completed GROWI to GROWI transfer successfully",
     "transfer_success": "Completed GROWI to GROWI transfer successfully",

+ 17 - 2
apps/app/public/static/locales/zh_CN/translation.json

@@ -782,11 +782,26 @@
     "discription_heading": "创建账户",
     "discription_heading": "创建账户",
     "discription": "用被邀请的电子邮件地址创建一个你的账户"
     "discription": "用被邀请的电子邮件地址创建一个你的账户"
   },
   },
-  "export_bulk": {
+  "page_export": {
     "failed_to_export": "导出失败",
     "failed_to_export": "导出失败",
     "failed_to_count_pages": "页面计数失败",
     "failed_to_count_pages": "页面计数失败",
     "export_page_markdown": "以Markdown格式导出页面",
     "export_page_markdown": "以Markdown格式导出页面",
-    "export_page_pdf": "以PDF格式导出页面"
+    "export_page_pdf": "以PDF格式导出页面",
+    "bulk_export": "导出页面及其下的所有页面",
+    "bulk_export_download_explanation": "导出完成后将发送通知。要下载导出的文件,请单击通知。",
+    "bulk_export_exec_time_warning": "如果页数较多,导出可能需要一段时间",
+    "large_bulk_export_warning": "为了节省系统资源,请避免连续导出大量页面",
+    "markdown": "Markdown",
+    "choose_export_format": "选择导出格式",
+    "bulk_export_started": "目前我们正在准备...",
+    "bulk_export_download_expired": "下载期限已过",
+    "bulk_export_job_expired": "由于导出时间太长,处理被中断",
+    "export_in_progress": "导出正在进行中",
+    "export_in_progress_explanation": "已在进行相同格式的导出。您要重新启动以导出最新的页面内容吗?",
+    "export_cancel_warning": "以下正在进行的导出将被取消",
+    "restart": "重新开始",
+    "format": "格式",
+    "started_on": "开始于"
   },
   },
   "message": {
   "message": {
     "successfully_connected": "连接成功!",
     "successfully_connected": "连接成功!",

+ 11 - 0
apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx

@@ -14,6 +14,7 @@ import AppSetting from './AppSetting';
 import FileUploadSetting from './FileUploadSetting';
 import FileUploadSetting from './FileUploadSetting';
 import MailSetting from './MailSetting';
 import MailSetting from './MailSetting';
 import { MaintenanceMode } from './MaintenanceMode';
 import { MaintenanceMode } from './MaintenanceMode';
+import PageBulkExportSettings from './PageBulkExportSettings';
 import QuestionnaireSettings from './QuestionnaireSettings';
 import QuestionnaireSettings from './QuestionnaireSettings';
 import SiteUrlSetting from './SiteUrlSetting';
 import SiteUrlSetting from './SiteUrlSetting';
 import V5PageMigration from './V5PageMigration';
 import V5PageMigration from './V5PageMigration';
@@ -108,6 +109,16 @@ const AppSettingsPageContents = (props: Props) => {
         </div>
         </div>
       </div>
       </div>
 
 
+      {/* TODO: Enable configuring bulk export for GROWI.cloud when it can be relased for cloud (https://redmine.weseek.co.jp/issues/163220) */}
+      {!adminAppContainer.state.isBulkExportDisabledForCloud && (
+        <div className="row mt-5">
+          <div className="col-lg-12">
+            <h2 className="admin-setting-header">{t('admin:app_setting.page_bulk_export_settings')}</h2>
+            <PageBulkExportSettings />
+          </div>
+        </div>
+      )}
+
       <div className="row mt-5">
       <div className="row mt-5">
         <div className="col-lg-12">
         <div className="col-lg-12">
           <h2 className="admin-setting-header">{t('admin:app_setting.questionnaire_settings')}</h2>
           <h2 className="admin-setting-header">{t('admin:app_setting.questionnaire_settings')}</h2>

+ 9 - 5
apps/app/src/client/components/Admin/App/FileUploadSetting.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'next-i18next';
 
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { FileUploadType } from '~/interfaces/file-uploader';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
@@ -16,9 +17,6 @@ import type { AzureSettingMoleculeProps } from './AzureSetting';
 import { GcsSettingMolecule } from './GcsSetting';
 import { GcsSettingMolecule } from './GcsSetting';
 import type { GcsSettingMoleculeProps } from './GcsSetting';
 import type { GcsSettingMoleculeProps } from './GcsSetting';
 
 
-
-const fileUploadTypes = ['aws', 'gcs', 'azure', 'gridfs', 'local'] as const;
-
 type FileUploadSettingMoleculeProps = {
 type FileUploadSettingMoleculeProps = {
   fileUploadType: string
   fileUploadType: string
   isFixedFileUploadByEnvVar: boolean
   isFixedFileUploadByEnvVar: boolean
@@ -45,7 +43,7 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
         </label>
         </label>
 
 
         <div className="col-md-6 py-2">
         <div className="col-md-6 py-2">
-          {fileUploadTypes.map((type) => {
+          {Object.values(FileUploadType).map((type) => {
             return (
             return (
               <div key={type} className="form-check form-check-inline">
               <div key={type} className="form-check form-check-inline">
                 <input
                 <input
@@ -67,7 +65,13 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
             <span className="material-symbols-outlined">help</span>
             <span className="material-symbols-outlined">help</span>
             <b>FIXED</b><br />
             <b>FIXED</b><br />
             {/* eslint-disable-next-line react/no-danger */}
             {/* eslint-disable-next-line react/no-danger */}
-            <b dangerouslySetInnerHTML={{ __html: t('admin:app_setting.fixed_by_env_var', { fileUploadType: props.envFileUploadType }) }} />
+            <b dangerouslySetInnerHTML={{
+              __html: t('admin:app_setting.fixed_by_env_var', {
+                envKey: 'FILE_UPLOAD',
+                envVar: props.envFileUploadType,
+              }),
+            }}
+            />
           </p>
           </p>
         )}
         )}
       </div>
       </div>

+ 136 - 0
apps/app/src/client/components/Admin/App/PageBulkExportSettings.tsx

@@ -0,0 +1,136 @@
+import {
+  useState, useCallback, useEffect,
+} from 'react';
+
+import { LoadingSpinner } from '@growi/ui/dist/components';
+import { useTranslation } from 'next-i18next';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useSWRxAppSettings } from '~/stores/admin/app-settings';
+
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const PageBulkExportSettings = (): JSX.Element => {
+  const { t } = useTranslation(['admin', 'commons']);
+
+  const { data, error, mutate } = useSWRxAppSettings();
+
+  const [isBulkExportPagesEnabled, setIsBulkExportPagesEnabled] = useState(data?.isBulkExportPagesEnabled);
+  const [bulkExportDownloadExpirationSeconds, setBulkExportDownloadExpirationSeconds] = useState(data?.bulkExportDownloadExpirationSeconds);
+
+  const changeBulkExportDownloadExpirationSeconds = (bulkExportDownloadExpirationDays: number) => {
+    const bulkExportDownloadExpirationSeconds = bulkExportDownloadExpirationDays * 24 * 60 * 60;
+    setBulkExportDownloadExpirationSeconds(bulkExportDownloadExpirationSeconds);
+  };
+
+  const onSubmitHandler = useCallback(async() => {
+    try {
+      await apiv3Put('/app-settings/page-bulk-export-settings', {
+        isBulkExportPagesEnabled,
+        bulkExportDownloadExpirationSeconds,
+      });
+      toastSuccess(t('commons:toaster.update_successed', { target: t('app_setting.questionnaire_settings') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    mutate();
+  }, [isBulkExportPagesEnabled, bulkExportDownloadExpirationSeconds, mutate, t]);
+
+  useEffect(() => {
+    if (data?.useOnlyEnvVarForFileUploadType) {
+      setIsBulkExportPagesEnabled(data?.envIsBulkExportPagesEnabled);
+    }
+    else {
+      setIsBulkExportPagesEnabled(data?.isBulkExportPagesEnabled);
+    }
+    setBulkExportDownloadExpirationSeconds(data?.bulkExportDownloadExpirationSeconds);
+  }, [data]);
+
+  const isLoading = data === undefined && error === undefined;
+
+  return (
+    <>
+      {isLoading && (
+        <div className="text-muted text-center mb-5">
+          <LoadingSpinner className="me-1 fs-3" />
+        </div>
+      )}
+
+      {!isLoading && (
+        <>
+          <p className="card custom-card bg-warning-subtle my-3">
+            {t('admin:app_setting.page_bulk_export_explanation')} <br />
+            <span className="text-danger mt-1">
+              {t('admin:app_setting.page_bulk_export_warning')}
+            </span>
+          </p>
+
+          <div className="my-4 row">
+            <label
+              className="text-start text-md-end col-md-3 col-form-label"
+            >
+            </label>
+
+            <div className="col-md-6">
+              <div className="form-check form-switch form-check-info">
+                <input
+                  type="checkbox"
+                  className="form-check-input"
+                  id="cbIsPageBulkExportEnabled"
+                  checked={isBulkExportPagesEnabled}
+                  disabled={data?.useOnlyEnvVarsForIsBulkExportPagesEnabled}
+                  onChange={e => setIsBulkExportPagesEnabled(e.target.checked)}
+                />
+                <label className="form-label form-check-label" htmlFor="cbIsPageBulkExportEnabled">
+                  {t('app_setting.enable_page_bulk_export')}
+                </label>
+              </div>
+              {data?.useOnlyEnvVarsForIsBulkExportPagesEnabled && (
+                <p className="form-text text-muted">
+                  {/* eslint-disable-next-line react/no-danger */}
+                  <b dangerouslySetInnerHTML={{
+                    __html: t('admin:app_setting.fixed_by_env_var', {
+                      envKey: 'BULK_EXPORT_PAGES_ENABLED',
+                      envVar: isBulkExportPagesEnabled,
+                    }),
+                  }}
+                  />
+                </p>
+              )}
+            </div>
+          </div>
+
+          <div className="mb-4">
+            <div className="row">
+              <label
+                className="text-start text-md-end col-md-3 col-form-label"
+              >
+                {t('app_setting.page_bulk_export_storage_period')}
+              </label>
+
+              <div className="col-md-2">
+                <select
+                  className="form-select"
+                  value={(bulkExportDownloadExpirationSeconds ?? 0) / (24 * 60 * 60)}
+                  onChange={(e) => { changeBulkExportDownloadExpirationSeconds(Number(e.target.value)) }}
+                >
+                  {Array.from({ length: 7 }, (_, i) => i + 1).map(number => (
+                    <option key={`be-download-expiration-option-${number}`} value={number}>
+                      {number} {t('admin:days')}
+                    </option>
+                  ))}
+                </select>
+              </div>
+            </div>
+          </div>
+
+          <AdminUpdateButtonRow onClick={onSubmitHandler} />
+        </>
+      )}
+    </>
+  );
+};
+
+export default PageBulkExportSettings;

+ 42 - 28
apps/app/src/client/components/Admin/App/QuestionnaireSettings.tsx

@@ -72,37 +72,51 @@ const QuestionnaireSettings = (): JSX.Element => {
 
 
       {!isLoading && (
       {!isLoading && (
         <>
         <>
-          <div className="my-4">
-            <div className="form-check form-switch form-check-info">
-              <input
-                type="checkbox"
-                className="form-check-input"
-                id="isQuestionnaireEnabled"
-                checked={isQuestionnaireEnabled}
-                onChange={onChangeIsQuestionnaireEnabledHandler}
-              />
-              <label className="form-label form-check-label" htmlFor="isQuestionnaireEnabled">
-                {t('app_setting.enable_questionnaire')}
-              </label>
+          <div className="my-4 row">
+            <label
+              className="text-start text-md-end col-md-3 col-form-label"
+            >
+            </label>
+
+            <div className="col-md-6">
+              <div className="form-check form-switch form-check-info">
+                <input
+                  type="checkbox"
+                  className="form-check-input"
+                  id="isQuestionnaireEnabled"
+                  checked={isQuestionnaireEnabled}
+                  onChange={onChangeIsQuestionnaireEnabledHandler}
+                />
+                <label className="form-label form-check-label" htmlFor="isQuestionnaireEnabled">
+                  {t('app_setting.enable_questionnaire')}
+                </label>
+              </div>
             </div>
             </div>
           </div>
           </div>
 
 
-          <div className="my-4">
-            <div className="form-check form-check-info">
-              <input
-                type="checkbox"
-                className="form-check-input"
-                id="isAppSiteUrlHashed"
-                checked={isAppSiteUrlHashed}
-                onChange={onChangeisAppSiteUrlHashedHandler}
-                disabled={!isQuestionnaireEnabled}
-              />
-              <label className="form-label form-check-label" htmlFor="isAppSiteUrlHashed">
-                {t('app_setting.anonymize_app_site_url')}
-              </label>
-              <p className="form-text text-muted small">
-                {t('app_setting.url_anonymization_explanation')}
-              </p>
+          <div className="my-4 row">
+            <label
+              className="text-start text-md-end col-md-3 col-form-label"
+            >
+            </label>
+
+            <div className="col-md-6">
+              <div className="form-check form-check-info">
+                <input
+                  type="checkbox"
+                  className="form-check-input"
+                  id="isAppSiteUrlHashed"
+                  checked={isAppSiteUrlHashed}
+                  onChange={onChangeisAppSiteUrlHashedHandler}
+                  disabled={!isQuestionnaireEnabled}
+                />
+                <label className="form-label form-check-label" htmlFor="isAppSiteUrlHashed">
+                  {t('app_setting.anonymize_app_site_url')}
+                </label>
+                <p className="form-text text-muted small">
+                  {t('app_setting.url_anonymization_explanation')}
+                </p>
+              </div>
             </div>
             </div>
           </div>
           </div>
 
 

+ 26 - 17
apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx

@@ -9,7 +9,7 @@ import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 
 
-import { useModelNotification } from './PageNotification';
+import { useModelNotification } from './ModelNotification';
 
 
 interface Props {
 interface Props {
   notification: IInAppNotification & HasObjectId
   notification: IInAppNotification & HasObjectId
@@ -24,9 +24,11 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
 
 
   const Notification = modelNotificationUtils?.Notification;
   const Notification = modelNotificationUtils?.Notification;
   const publishOpen = modelNotificationUtils?.publishOpen;
   const publishOpen = modelNotificationUtils?.publishOpen;
+  const clickLink = modelNotificationUtils?.clickLink;
+  const isDisabled = modelNotificationUtils?.isDisabled;
   const { mutate: mutateNotificationCount } = useSWRxInAppNotificationStatus();
   const { mutate: mutateNotificationCount } = useSWRxInAppNotificationStatus();
 
 
-  if (Notification == null || publishOpen == null) {
+  if (Notification == null) {
     return <></>;
     return <></>;
   }
   }
 
 
@@ -38,7 +40,9 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       mutateNotificationCount();
       mutateNotificationCount();
     }
     }
 
 
-    publishOpen();
+    if (isDisabled) return;
+
+    publishOpen?.();
   };
   };
 
 
   const renderActionUserPictures = (): JSX.Element => {
   const renderActionUserPictures = (): JSX.Element => {
@@ -61,21 +65,26 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
   };
   };
 
 
   return (
   return (
-    <div className="list-group-item list-group-item-action" onClick={() => clickHandler(notification)} style={{ cursor: 'pointer' }}>
-      <div className="d-flex align-items-center">
-        <span
-          className={`${notification.status === InAppNotificationStatuses.STATUS_UNOPENED
-            ? 'grw-unopend-notification'
-            : 'ms-2'
-          } rounded-circle me-3`}
-        >
-        </span>
-
-        {renderActionUserPictures()}
-
-        <Notification />
+    <div className="list-group-item list-group-item-action" style={{ cursor: 'pointer' }}>
+      <a
+        href={isDisabled ? undefined : clickLink}
+        onClick={() => clickHandler(notification)}
+      >
+        <div className="d-flex align-items-center">
+          <span
+            className={`${notification.status === InAppNotificationStatuses.STATUS_UNOPENED
+              ? 'grw-unopend-notification'
+              : 'ms-2'
+            } rounded-circle me-3`}
+          >
+          </span>
+
+          {renderActionUserPictures()}
+
+          <Notification />
 
 
-      </div>
+        </div>
+      </a>
     </div>
     </div>
   );
   );
 };
 };

+ 0 - 0
apps/app/src/client/components/InAppNotification/PageNotification/ModelNotification.module.scss → apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.module.scss


+ 13 - 6
apps/app/src/client/components/InAppNotification/PageNotification/ModelNotification.tsx → apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.tsx

@@ -15,20 +15,27 @@ type Props = {
   actionMsg: string
   actionMsg: string
   actionIcon: string
   actionIcon: string
   actionUsers: string
   actionUsers: string
+  hideActionUsers?: boolean
+  subMsg?: JSX.Element
 };
 };
 
 
-export const ModelNotification: FC<Props> = (props) => {
-  const {
-    notification, actionMsg, actionIcon, actionUsers,
-  } = props;
+export const ModelNotification: FC<Props> = ({
+  notification,
+  actionMsg,
+  actionIcon,
+  actionUsers,
+  hideActionUsers = false,
+  subMsg,
+}: Props) => {
 
 
   return (
   return (
     <div className={`${styles['modal-notification']} p-2 overflow-hidden`}>
     <div className={`${styles['modal-notification']} p-2 overflow-hidden`}>
       <div className="text-truncate page-title">
       <div className="text-truncate page-title">
-        <b>{actionUsers}</b>
-        {actionMsg}
+        {hideActionUsers ? <></> : <b>{actionUsers}</b>}
+        {` ${actionMsg}`}
         <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
         <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
       </div>
       </div>
+      { subMsg }
       <span className="material-symbols-outlined me-2">{actionIcon}</span>
       <span className="material-symbols-outlined me-2">{actionIcon}</span>
       <FormattedDistanceDate
       <FormattedDistanceDate
         id={notification._id}
         id={notification._id}

+ 69 - 0
apps/app/src/client/components/InAppNotification/ModelNotification/PageBulkExportJobModelNotification.tsx

@@ -0,0 +1,69 @@
+import React from 'react';
+
+import { isPopulated, type HasObjectId } from '@growi/core';
+import { useTranslation } from 'react-i18next';
+
+import type { IPageBulkExportJobHasId } from '~/features/page-bulk-export/interfaces/page-bulk-export';
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import type { IInAppNotification } from '~/interfaces/in-app-notification';
+import * as pageBulkExportJobSerializers from '~/models/serializers/in-app-notification-snapshot/page-bulk-export-job';
+
+import { ModelNotification } from './ModelNotification';
+import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
+
+import type { ModelNotificationUtils } from '.';
+
+
+export const usePageBulkExportJobModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
+
+  const { t } = useTranslation();
+  const { actionMsg, actionIcon } = useActionMsgAndIconForModelNotification(notification);
+
+  const isPageBulkExportJobModelNotification = (
+      notification: IInAppNotification & HasObjectId,
+  ): notification is IInAppNotification<IPageBulkExportJobHasId> & HasObjectId => {
+    return notification.targetModel === SupportedTargetModel.MODEL_PAGE_BULK_EXPORT_JOB;
+  };
+
+  if (!isPageBulkExportJobModelNotification(notification)) {
+    return null;
+  }
+
+  const actionUsers = notification.user.username;
+
+  notification.parsedSnapshot = pageBulkExportJobSerializers.parseSnapshot(notification.snapshot);
+
+  const getSubMsg = (): React.ReactElement => {
+    if (notification.action === SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED && notification.target == null) {
+      return <div className="text-danger"><small>{t('page_export.bulk_export_download_expired')}</small></div>;
+    }
+    if (notification.action === SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED) {
+      return <div className="text-danger"><small>{t('page_export.bulk_export_job_expired')}</small></div>;
+    }
+    return <></>;
+  };
+
+  const Notification = () => {
+    return (
+      <ModelNotification
+        notification={notification}
+        actionMsg={actionMsg}
+        actionIcon={actionIcon}
+        actionUsers={actionUsers}
+        hideActionUsers
+        subMsg={getSubMsg()}
+      />
+    );
+  };
+
+  const clickLink = (notification.action === SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED
+    && notification.target?.attachment != null && isPopulated(notification.target?.attachment))
+    ? notification.target.attachment.downloadPathProxied : undefined;
+
+  return {
+    Notification,
+    clickLink,
+    isDisabled: notification.target == null,
+  };
+
+};

+ 2 - 8
apps/app/src/client/components/InAppNotification/PageNotification/PageModelNotification.tsx → apps/app/src/client/components/InAppNotification/ModelNotification/PageModelNotification.tsx

@@ -1,6 +1,4 @@
-import React, {
-  FC, useCallback,
-} from 'react';
+import React, { useCallback } from 'react';
 
 
 import type { IPage, HasObjectId } from '@growi/core';
 import type { IPage, HasObjectId } from '@growi/core';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
@@ -12,11 +10,7 @@ import * as pageSerializers from '~/models/serializers/in-app-notification-snaps
 import { ModelNotification } from './ModelNotification';
 import { ModelNotification } from './ModelNotification';
 import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 
 
-
-export interface ModelNotificationUtils {
-  Notification: FC
-  publishOpen: () => void
-}
+import type { ModelNotificationUtils } from '.';
 
 
 export const usePageModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
 export const usePageModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
 
 

+ 2 - 1
apps/app/src/client/components/InAppNotification/PageNotification/UserModelNotification.tsx → apps/app/src/client/components/InAppNotification/ModelNotification/UserModelNotification.tsx

@@ -7,9 +7,10 @@ import { SupportedTargetModel } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
 
 import { ModelNotification } from './ModelNotification';
 import { ModelNotification } from './ModelNotification';
-import { ModelNotificationUtils } from './PageModelNotification';
 import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 
 
+import type { ModelNotificationUtils } from '.';
+
 
 
 export const useUserModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
 export const useUserModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
 
 

+ 31 - 0
apps/app/src/client/components/InAppNotification/ModelNotification/index.tsx

@@ -0,0 +1,31 @@
+import type { FC } from 'react';
+
+import type { HasObjectId } from '@growi/core';
+
+import type { IInAppNotification } from '~/interfaces/in-app-notification';
+
+
+import { usePageBulkExportJobModelNotification } from './PageBulkExportJobModelNotification';
+import { usePageModelNotification } from './PageModelNotification';
+import { useUserModelNotification } from './UserModelNotification';
+
+export interface ModelNotificationUtils {
+  Notification: FC
+  publishOpen?: () => void
+  clickLink?: string
+  // Whether actions from clicking notification is disabled or not.
+  // User can still open the notification when true.
+  isDisabled?: boolean
+}
+
+export const useModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
+
+  const pageModelNotificationUtils = usePageModelNotification(notification);
+  const userModelNotificationUtils = useUserModelNotification(notification);
+  const pageBulkExportResultModelNotificationUtils = usePageBulkExportJobModelNotification(notification);
+
+  const modelNotificationUtils = pageModelNotificationUtils ?? userModelNotificationUtils ?? pageBulkExportResultModelNotificationUtils;
+
+
+  return modelNotificationUtils;
+};

+ 9 - 0
apps/app/src/client/components/InAppNotification/PageNotification/useActionAndMsg.ts → apps/app/src/client/components/InAppNotification/ModelNotification/useActionAndMsg.ts

@@ -70,6 +70,15 @@ export const useActionMsgAndIconForModelNotification = (notification: IInAppNoti
       actionMsg = 'requested registration approval';
       actionMsg = 'requested registration approval';
       actionIcon = 'add_comment';
       actionIcon = 'add_comment';
       break;
       break;
+    case SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED:
+      actionMsg = 'export completed for';
+      actionIcon = 'download';
+      break;
+    case SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED:
+    case SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED:
+      actionMsg = 'export failed for';
+      actionIcon = 'error';
+      break;
     default:
     default:
       actionMsg = '';
       actionMsg = '';
       actionIcon = '';
       actionIcon = '';

+ 0 - 19
apps/app/src/client/components/InAppNotification/PageNotification/index.tsx

@@ -1,19 +0,0 @@
-import type { HasObjectId } from '@growi/core';
-
-import type { IInAppNotification } from '~/interfaces/in-app-notification';
-
-
-import { usePageModelNotification, type ModelNotificationUtils } from './PageModelNotification';
-import { useUserModelNotification } from './UserModelNotification';
-
-
-export const useModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
-
-  const pageModelNotificationUtils = usePageModelNotification(notification);
-  const userModelNotificationUtils = useUserModelNotification(notification);
-
-  const modelNotificationUtils = pageModelNotificationUtils ?? userModelNotificationUtils;
-
-
-  return modelNotificationUtils;
-};

+ 20 - 22
apps/app/src/client/components/Me/AccessTokenForm.tsx

@@ -3,10 +3,10 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
 import { useForm } from 'react-hook-form';
 
 
-
 import type { IAccessTokenInfo } from '~/interfaces/access-token';
 import type { IAccessTokenInfo } from '~/interfaces/access-token';
 import type { Scope } from '~/interfaces/scope';
 import type { Scope } from '~/interfaces/scope';
-import { SCOPE } from '~/interfaces/scope';
+
+import { AccessTokenScopeSelect } from './AccessTokenScopeSelect';
 
 
 const MAX_DESCRIPTION_LENGTH = 200;
 const MAX_DESCRIPTION_LENGTH = 200;
 
 
@@ -17,11 +17,9 @@ type AccessTokenFormProps = {
 type FormInputs = {
 type FormInputs = {
   expiredAt: string;
   expiredAt: string;
   description: string;
   description: string;
-  // TODO: Implement scope selection
   scopes: Scope[];
   scopes: Scope[];
 }
 }
 
 
-// TODO: Implement scope selection
 export const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Element => {
 export const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Element => {
   const { submitHandler } = props;
   const { submitHandler } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -35,10 +33,12 @@ export const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Ele
     register,
     register,
     handleSubmit,
     handleSubmit,
     formState: { errors, isValid },
     formState: { errors, isValid },
+    watch,
   } = useForm<FormInputs>({
   } = useForm<FormInputs>({
     defaultValues: {
     defaultValues: {
       expiredAt: defaultExpiredAtStr,
       expiredAt: defaultExpiredAtStr,
       description: '',
       description: '',
+      scopes: [],
     },
     },
   });
   });
 
 
@@ -111,25 +111,23 @@ export const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Ele
           </div>
           </div>
 
 
           <div className="mb-3">
           <div className="mb-3">
-            <label htmlFor="scope" className="form-label">{t('page_me_access_token.scope')}</label>
-            <div className="form-text mb-2">{t('page_me_access_token.form.scope_desc')}</div>
-            <div className="form-text mb-2">
-              <input
-                type="checkbox"
-                id="scope-read-user"
-                value={SCOPE.READ.USER_SETTINGS.ALL}
-                {...register('scopes')}
-              />
-              <label htmlFor="scope-read-user" className="ms-2">Read User</label>
-            </div>
+            <label htmlFor="scopes" className="form-label">
+              {t('page_me_access_token.scope')}
+            </label>
+            <AccessTokenScopeSelect
+              selectedScopes={watch('scopes')}
+              register={register('scopes', {
+                required: t('input_validation.message.required', { param: t('page_me_access_token.scope') }),
+              })}
+            />
+            {errors.scopes && (
+              <div className="invalid-feedback">
+                {errors.scopes.message}
+              </div>
+            )}
+
             <div className="form-text mb-2">
             <div className="form-text mb-2">
-              <input
-                type="checkbox"
-                id="scope-write-user"
-                value={SCOPE.WRITE.USER_SETTINGS.ALL}
-                {...register('scopes')}
-              />
-              <label htmlFor="scope-write-user" className="ms-2">Write User</label>
+              {t('page_me_access_token.form.scope_desc')}
             </div>
             </div>
           </div>
           </div>
 
 

+ 36 - 0
apps/app/src/client/components/Me/AccessTokenScopeList.module.scss

@@ -0,0 +1,36 @@
+$baseMargin: 20px;
+
+.access-token-scope-list :global {
+  .indentation {
+    &.indentation-level-1 {
+      margin-left: $baseMargin;
+    }
+    &.indentation-level-2 {
+      margin-left: $baseMargin * 2;
+    }
+    &.indentation-level-3 {
+      margin-left: $baseMargin * 3;
+    }
+    &.indentation-level-4 {
+      margin-left: $baseMargin * 4;
+    }
+    &.indentation-level-5 {
+      margin-left: $baseMargin * 5;
+    }
+    &.indentation-level-6 {
+      margin-left: $baseMargin * 6;
+    }
+    &.indentation-level-7 {
+      margin-left: $baseMargin * 7;
+    }
+    &.indentation-level-8 {
+      margin-left: $baseMargin * 8;
+    }
+    &.indentation-level-9 {
+      margin-left: $baseMargin * 9;
+    }
+    &.indentation-level-10 {
+      margin-left: $baseMargin * 10;
+    }
+  }
+}

+ 89 - 0
apps/app/src/client/components/Me/AccessTokenScopeList.tsx

@@ -0,0 +1,89 @@
+import React from 'react';
+
+import type { UseFormRegisterReturn } from 'react-hook-form';
+
+import { useIsDeviceLargerThanMd } from '~/stores/ui';
+
+import type { Scope } from '../../../interfaces/scope';
+
+import styles from './AccessTokenScopeList.module.scss';
+
+const moduleClass = styles['access-token-scope-list'] ?? '';
+
+interface scopeObject {
+  [key: string]: Scope | scopeObject;
+}
+
+interface AccessTokenScopeListProps {
+  scopeObject: scopeObject;
+  register: UseFormRegisterReturn<'scopes'>;
+  disabledScopes: Set<Scope>
+  level?: number;
+}
+
+/**
+ * Renders the permission object recursively as nested checkboxes.
+ */
+export const AccessTokenScopeList: React.FC<AccessTokenScopeListProps> = ({
+  scopeObject,
+  register,
+  disabledScopes,
+  level = 0,
+}) => {
+
+  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+
+  // Convert object into an array to determine "first vs. non-first" elements
+  const entries = Object.entries(scopeObject);
+
+  return (
+    <>
+      {entries.map(([scopeKey, scopeValue], idx) => {
+        // Get indentation class based on level
+        // Example: Insert <hr> only for levels 0 or 1, except for the first item
+        const showHr = (level === 0 || level === 1) && idx !== 0;
+
+        if (typeof scopeValue === 'object') {
+          return (
+            <div key={scopeKey} className={moduleClass}>
+              {showHr && <hr className="my-1" />}
+              <div className="my-1 row">
+                <div className="col-md-5 ">
+                  <label className={`form-check-label fw-bold indentation indentation-level-${level}`}>{scopeKey}</label>
+                </div>
+                <div className={`col form-text fw-bold ${isDeviceLargerThanMd ? '' : 'text-end'}`}>desc for {scopeKey}</div>
+              </div>
+
+              {/* Render recursively */}
+              <AccessTokenScopeList
+                scopeObject={scopeValue as scopeObject}
+                register={register}
+                level={level + 1}
+                disabledScopes={disabledScopes}
+              />
+            </div>
+          );
+        }
+        // If it's a string, render a checkbox
+        return (
+          <div key={scopeKey} className={`row my-1 ${moduleClass}`}>
+            <div className="col-md-5 indentation">
+              <input
+                className={`form-check-input indentation indentation-level-${level}`}
+                type="checkbox"
+                id={scopeValue as string}
+                disabled={disabledScopes.has(scopeValue)}
+                value={scopeValue as string}
+                {...register}
+              />
+              <label className="form-check-label ms-2" htmlFor={scopeValue as string}>
+                {scopeKey}
+              </label>
+            </div>
+            <div className={`col form-text ${isDeviceLargerThanMd ? '' : 'text-end'}`}>desc for {scopeKey}</div>
+          </div>
+        );
+      })}
+    </>
+  );
+};

+ 42 - 0
apps/app/src/client/components/Me/AccessTokenScopeSelect.tsx

@@ -0,0 +1,42 @@
+import React, { useEffect, useState, useMemo } from 'react';
+
+import type { UseFormRegisterReturn } from 'react-hook-form';
+
+import { extractScopes, getDisabledScopes, parseScopes } from '~/client/util/scope-util';
+import { useIsAdmin } from '~/stores-universal/context';
+
+import type { Scope } from '../../../interfaces/scope';
+import { SCOPE } from '../../../interfaces/scope';
+
+import { AccessTokenScopeList } from './AccessTokenScopeList';
+
+/**
+ * Props for AccessTokenScopeSelect
+ */
+type AccessTokenScopeSelectProps = {
+  /** React Hook Form's register function for a field named "scopes" */
+  register: UseFormRegisterReturn<'scopes'>;
+  selectedScopes: Scope[];
+};
+
+/**
+ * Displays a list of permissions in a recursive, nested checkbox interface.
+ */
+export const AccessTokenScopeSelect: React.FC<AccessTokenScopeSelectProps> = ({ register, selectedScopes }) => {
+  const [disabledScopes, setDisabledScopes] = useState<Set<Scope>>(new Set());
+  const { data: isAdmin } = useIsAdmin();
+
+  const ScopesMap = useMemo(() => parseScopes({ scopes: SCOPE, isAdmin }), [isAdmin]);
+  const extractedScopes = useMemo(() => extractScopes(ScopesMap), [ScopesMap]);
+
+  useEffect(() => {
+    const disabledSet = getDisabledScopes(selectedScopes, extractedScopes);
+    setDisabledScopes(disabledSet);
+  }, [selectedScopes, extractedScopes]);
+
+  return (
+    <div className="border rounded">
+      <AccessTokenScopeList scopeObject={ScopesMap} register={register} disabledScopes={disabledScopes} />
+    </div>
+  );
+};

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

@@ -14,11 +14,12 @@ import Link from 'next/link';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import Sticky from 'react-stickynode';
 import Sticky from 'react-stickynode';
-import { DropdownItem, UncontrolledTooltip } from 'reactstrap';
+import { Tooltip, DropdownItem, UncontrolledTooltip } from 'reactstrap';
 
 
 import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
 import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
+import { usePageBulkExportSelectModal } from '~/features/page-bulk-export/client/stores/modal';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
 import {
 import {
@@ -36,7 +37,7 @@ import {
 } from '~/stores/ui';
 } from '~/stores/ui';
 import {
 import {
   useCurrentPathname,
   useCurrentPathname,
-  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsLocalAccountRegistrationEnabled, useIsSharedUser, useShareLinkId,
+  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsBulkExportPagesEnabled, useIsLocalAccountRegistrationEnabled, useIsSharedUser, useShareLinkId,
 } from '~/stores-universal/context';
 } from '~/stores-universal/context';
 import { useEditorMode } from '~/stores-universal/ui';
 import { useEditorMode } from '~/stores-universal/ui';
 
 
@@ -75,9 +76,11 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
+  const { data: isBulkExportPagesEnabled } = useIsBulkExportPagesEnabled();
 
 
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
+  const { open: openPageBulkExportSelectModal } = usePageBulkExportSelectModal();
 
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
 
 
@@ -134,9 +137,22 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         className="grw-page-control-dropdown-item"
         className="grw-page-control-dropdown-item"
       >
       >
         <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">cloud_download</span>
         <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">cloud_download</span>
-        {t('export_bulk.export_page_markdown')}
+        {t('page_export.export_page_markdown')}
       </DropdownItem>
       </DropdownItem>
 
 
+      {/* Bulk export */}
+      {isBulkExportPagesEnabled && (
+        <span id="bulkExportDropdownItem">
+          <DropdownItem
+            onClick={openPageBulkExportSelectModal}
+            className="grw-page-control-dropdown-item"
+          >
+            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">cloud_download</span>
+            {t('page_export.bulk_export')}
+          </DropdownItem>
+        </span>
+      )}
+
       <DropdownItem divider />
       <DropdownItem divider />
 
 
       {/*
       {/*

+ 2 - 2
apps/app/src/client/components/Navbar/PageEditorModeManager.tsx

@@ -1,7 +1,7 @@
 import React, { type ReactNode, useCallback, useMemo } from 'react';
 import React, { type ReactNode, useCallback, useMemo } from 'react';
 
 
 import { Origin } from '@growi/core';
 import { Origin } from '@growi/core';
-import { normalizePath } from '@growi/core/dist/utils/path-utils';
+import { getParentPath } from '@growi/core/dist/utils/path-utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { useCreatePage } from '~/client/services/create-page';
 import { useCreatePage } from '~/client/services/create-page';
@@ -78,7 +78,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
     }
     }
 
 
     try {
     try {
-      const parentPath = path != null ? normalizePath(path.split('/').slice(0, -1).join('/')) : undefined; // does not have to exist
+      const parentPath = path != null ? getParentPath(path) : undefined; // does not have to exist
       await create(
       await create(
         {
         {
           path, parentPath, wip: shouldCreateWipPage(path), origin: Origin.View,
           path, parentPath, wip: shouldCreateWipPage(path), origin: Origin.View,

+ 1 - 1
apps/app/src/client/components/SearchPage/SearchResultContent.tsx

@@ -59,7 +59,7 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       className="grw-page-control-dropdown-item"
       className="grw-page-control-dropdown-item"
     >
     >
       <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">cloud_download</span>
       <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">cloud_download</span>
-      {t('export_bulk.export_page_markdown')}
+      {t('page_export.export_page_markdown')}
     </DropdownItem>
     </DropdownItem>
   );
   );
 };
 };

+ 6 - 1
apps/app/src/client/services/AdminAppContainer.js

@@ -76,6 +76,9 @@ export default class AdminAppContainer extends Container {
       isEnabledPlugins: true,
       isEnabledPlugins: true,
 
 
       isMaintenanceMode: false,
       isMaintenanceMode: false,
+
+      // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
+      isBulkExportDisabledForCloud: false,
     };
     };
 
 
   }
   }
@@ -149,6 +152,9 @@ export default class AdminAppContainer extends Container {
 
 
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
       isMaintenanceMode: appSettingsParams.isMaintenanceMode,
       isMaintenanceMode: appSettingsParams.isMaintenanceMode,
+
+      // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
+      isBulkExportDisabledForCloud: appSettingsParams.isBulkExportDisabledForCloud,
     });
     });
 
 
     // if useOnlyEnvVarForFileUploadType is true, get fileUploadType from only env var and make the forms fixed.
     // if useOnlyEnvVarForFileUploadType is true, get fileUploadType from only env var and make the forms fixed.
@@ -157,7 +163,6 @@ export default class AdminAppContainer extends Container {
       this.setState({ fileUploadType: appSettingsParams.envFileUploadType });
       this.setState({ fileUploadType: appSettingsParams.envFileUploadType });
       this.setState({ isFixedFileUploadByEnvVar: true });
       this.setState({ isFixedFileUploadByEnvVar: true });
     }
     }
-
   }
   }
 
 
   /**
   /**

+ 133 - 0
apps/app/src/client/util/scope-util.test.ts

@@ -0,0 +1,133 @@
+import { describe, it, expect } from 'vitest';
+
+import { ALL_SIGN } from '../../interfaces/scope';
+
+import { parseScopes, getDisabledScopes, extractScopes } from './scope-util';
+
+describe('scope-util', () => {
+
+  const mockScopes = {
+    READ: {
+      USER: 'read:user',
+      ADMIN: {
+        SETTING: 'read:admin:setting',
+        ALL: 'read:admin:all',
+      },
+      ALL: 'read:all',
+    },
+    WRITE: {
+      USER: 'write:user',
+      ADMIN: {
+        SETTING: 'write:admin:setting',
+        ALL: 'write:admin:all',
+      },
+      ALL: 'write:all',
+    },
+  };
+
+  it('should parse scopes correctly for non-admin', () => {
+    const result = parseScopes({ scopes: mockScopes, isAdmin: false });
+
+    // Check that admin scopes are excluded
+    expect(result.ADMIN).toBeUndefined();
+    expect(result.ALL).toBeUndefined();
+
+    // Check that user scopes are included
+    expect(result.USER).toBeDefined();
+    expect(result.USER['read:user']).toBe('read:user');
+    expect(result.USER['write:user']).toBe('write:user');
+  });
+
+  it('should include admin scopes for admin users', () => {
+    const result = parseScopes({ scopes: mockScopes, isAdmin: true });
+
+    // Check that admin scopes are included
+    expect(result.ADMIN).toBeDefined();
+    expect(result.ALL).toBeDefined();
+
+    // Check admin settings
+    expect(result.ADMIN['admin:setting']['read:admin:setting']).toBe('read:admin:setting');
+    expect(result.ADMIN['admin:setting']['write:admin:setting']).toBe('write:admin:setting');
+
+    // Check ALL category
+    expect(result.ALL['read:all']).toBe('read:all');
+    expect(result.ALL['write:all']).toBe('write:all');
+  });
+
+  it('should return empty set when no scopes are selected', () => {
+    const result = getDisabledScopes([], ['read:user', 'write:user']);
+    expect(result.size).toBe(0);
+  });
+
+  it('should disable specific scopes when a wildcard is selected', () => {
+    const selectedScopes = [`read:${ALL_SIGN}`];
+    const availableScopes = ['read:user', 'read:admin', 'write:user', `read:${ALL_SIGN}`];
+
+    const result = getDisabledScopes(selectedScopes, availableScopes);
+
+    // Should disable all read: scopes except the wildcard itself
+    expect(result.has('read:user')).toBe(true);
+    expect(result.has('read:admin')).toBe(true);
+    expect(result.has(`read:${ALL_SIGN}`)).toBe(false);
+    expect(result.has('write:user')).toBe(false);
+  });
+
+  it('should handle multiple wildcard selections', () => {
+    const selectedScopes = [`read:${ALL_SIGN}`, `write:${ALL_SIGN}`];
+    const availableScopes = [
+      'read:user', 'read:admin', `read:${ALL_SIGN}`,
+      'write:user', 'write:admin', `write:${ALL_SIGN}`,
+    ];
+
+    const result = getDisabledScopes(selectedScopes, availableScopes);
+
+    // Should disable all specific scopes under both wildcards
+    expect(result.has('read:user')).toBe(true);
+    expect(result.has('read:admin')).toBe(true);
+    expect(result.has('write:user')).toBe(true);
+    expect(result.has('write:admin')).toBe(true);
+    expect(result.has(`read:${ALL_SIGN}`)).toBe(false);
+    expect(result.has(`write:${ALL_SIGN}`)).toBe(false);
+  });
+
+  it('should extract all scope strings from a nested object', () => {
+    const scopeObj = {
+      USER: {
+        'read:user': 'read:user',
+        'write:user': 'write:user',
+      },
+      ADMIN: {
+        'ADMIN:SETTING': {
+          'read:admin:setting': 'read:admin:setting',
+          'write:admin:setting': 'write:admin:setting',
+        },
+      },
+    };
+
+    const result = extractScopes(scopeObj);
+
+    expect(result).toContain('read:user');
+    expect(result).toContain('write:user');
+    expect(result).toContain('read:admin:setting');
+    expect(result).toContain('write:admin:setting');
+    expect(result.length).toBe(4);
+  });
+
+  it('should return empty array for empty object', () => {
+    const result = extractScopes({});
+    expect(result).toEqual([]);
+  });
+
+  it('should handle objects with no string values', () => {
+    const scopeObj = {
+      level1: {
+        level2: {
+          level3: {},
+        },
+      },
+    };
+
+    const result = extractScopes(scopeObj);
+    expect(result).toEqual([]);
+  });
+});

+ 143 - 0
apps/app/src/client/util/scope-util.ts

@@ -0,0 +1,143 @@
+import type { Scope } from '~/interfaces/scope';
+import { ALL_SIGN } from '~/interfaces/scope';
+
+// Data structure for the final merged scopes
+interface ScopeMap {
+  [key: string]: Scope | ScopeMap;
+}
+
+// Input object with arbitrary action keys (e.g., READ, WRITE)
+type ScopesInput = Record<string, any>;
+
+
+function parseSubScope(
+    parentKey: string,
+    subObjForActions: Record<string, any>,
+    actions: string[],
+): ScopeMap {
+  const result: ScopeMap = {};
+
+  for (const action of actions) {
+    if (typeof subObjForActions[action] === 'string') {
+      result[`${action.toLowerCase()}:${parentKey.toLowerCase()}`] = subObjForActions[action];
+      subObjForActions[action] = undefined;
+    }
+  }
+
+  const childKeys = new Set<string>();
+  for (const action of actions) {
+    const obj = subObjForActions[action];
+    if (obj && typeof obj === 'object') {
+      Object.keys(obj).forEach(k => childKeys.add(k));
+    }
+  }
+
+  for (const ck of childKeys) {
+    if (ck === 'ALL') {
+      for (const action of actions) {
+        const val = subObjForActions[action]?.[ck];
+        if (typeof val === 'string') {
+          result[`${action.toLowerCase()}:${parentKey.toLowerCase()}:all`] = val as Scope;
+        }
+      }
+      continue;
+    }
+
+    const newKey = `${parentKey}:${ck}`;
+    const childSubObj: Record<string, any> = {};
+    for (const action of actions) {
+      childSubObj[action] = subObjForActions[action]?.[ck];
+    }
+
+    result[newKey] = parseSubScope(newKey, childSubObj, actions);
+  }
+
+  return result;
+}
+
+export function parseScopes({ scopes, isAdmin = false }: { scopes: ScopesInput ; isAdmin?: boolean }): ScopeMap {
+  const actions = Object.keys(scopes);
+  const topKeys = new Set<string>();
+
+  // Collect all top-level keys (e.g., ALL, ADMIN, USER) across all actions
+  for (const action of actions) {
+    Object.keys(scopes[action] || {}).forEach(k => topKeys.add(k));
+  }
+
+  const result: ScopeMap = {};
+
+  for (const key of topKeys) {
+    // Skip 'ADMIN' key if isAdmin is true
+    if (!isAdmin && (key === 'ADMIN' || key === 'ALL')) {
+      continue;
+    }
+
+    if (key === 'ALL') {
+      const allObj: ScopeMap = {};
+      for (const action of actions) {
+        const val = scopes[action]?.[key];
+        if (typeof val === 'string') {
+          allObj[`${action.toLowerCase()}:all`] = val as Scope;
+        }
+      }
+      result.ALL = allObj;
+    }
+    else {
+      const subObjForActions: Record<string, any> = {};
+      for (const action of actions) {
+        subObjForActions[action] = scopes[action]?.[key];
+      }
+      result[key] = parseSubScope(key, subObjForActions, actions);
+    }
+  }
+
+  return result;
+}
+
+/**
+ * Determines which scopes should be disabled based on wildcard selections
+ */
+export function getDisabledScopes(selectedScopes: Scope[], availableScopes: string[]): Set<Scope> {
+  const disabledSet = new Set<Scope>();
+
+
+  // If no selected scopes, return empty set
+  if (!selectedScopes || selectedScopes.length === 0) {
+    return disabledSet;
+  }
+
+  selectedScopes.forEach((scope) => {
+    // Check if the scope is in the form `xxx:*`
+    if (scope.endsWith(`:${ALL_SIGN}`)) {
+      // Convert something like `read:*` into the prefix `read:`
+      const prefix = scope.replace(`:${ALL_SIGN}`, ':');
+
+      // Disable all scopes that start with the prefix (but are not the selected scope itself)
+      availableScopes.forEach((s: Scope) => {
+        if (s.startsWith(prefix) && s !== scope) {
+          disabledSet.add(s);
+        }
+      });
+    }
+  });
+
+  return disabledSet;
+}
+
+/**
+ * Extracts all scope strings from a nested ScopeMap object
+ */
+export function extractScopes(obj: Record<string, any>): string[] {
+  let result: string[] = [];
+
+  Object.values(obj).forEach((value) => {
+    if (typeof value === 'string') {
+      result.push(value);
+    }
+    else if (typeof value === 'object' && !Array.isArray(value)) {
+      result = result.concat(extractScopes(value));
+    }
+  });
+
+  return result;
+}

+ 3 - 0
apps/app/src/components/Layout/BasicLayout.tsx

@@ -40,6 +40,8 @@ const DeleteBookmarkFolderModal = dynamic(
   () => import('~/client/components/DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false },
   () => import('~/client/components/DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false },
 );
 );
 const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
 const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
+const PageBulkExportSelectModal = dynamic(() => import('../../features/page-bulk-export/client/components/PageBulkExportSelectModal'), { ssr: false });
+
 const AiAssistantManagementModal = dynamic(
 const AiAssistantManagementModal = dynamic(
   () => import('~/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal')
   () => import('~/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal')
     .then(mod => mod.AiAssistantManagementModal), { ssr: false },
     .then(mod => mod.AiAssistantManagementModal), { ssr: false },
@@ -86,6 +88,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
       <HotkeysManager />
       <HotkeysManager />
 
 
       <ShortcutsModal />
       <ShortcutsModal />
+      <PageBulkExportSelectModal />
       <GrantedGroupsInheritanceSelectModal />
       <GrantedGroupsInheritanceSelectModal />
       <SystemVersion showShortcutsButton />
       <SystemVersion showShortcutsButton />
     </RawLayout>
     </RawLayout>

+ 498 - 0
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -29,6 +29,21 @@ interface AuthorizedRequest extends Request {
   user?: any
   user?: any
 }
 }
 
 
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      SyncStatus:
+ *        type: object
+ *        properties:
+ *          isExecutingSync:
+ *            type: boolean
+ *          totalCount:
+ *            type: number
+ *          count:
+ *            type: number
+ */
 module.exports = (crowi: Crowi): Router => {
 module.exports = (crowi: Crowi): Router => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
@@ -81,6 +96,54 @@ module.exports = (crowi: Crowi): Router => {
     ],
     ],
   };
   };
 
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: listExternalUserGroups
+   *         summary: /external-user-groups
+   *         parameters:
+   *           - name: page
+   *             in: query
+   *             schema:
+   *               type: integer
+   *             description: Page number for pagination
+   *           - name: limit
+   *             in: query
+   *             schema:
+   *               type: integer
+   *             description: Number of items per page
+   *           - name: offset
+   *             in: query
+   *             schema:
+   *               type: integer
+   *             description: Offset for pagination
+   *           - name: pagination
+   *             in: query
+   *             schema:
+   *               type: boolean
+   *             description: Whether to enable pagination
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved external user groups
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     userGroups:
+   *                       type: array
+   *                       items:
+   *                         type: object
+   *                     totalUserGroups:
+   *                       type: integer
+   *                     pagingLimit:
+   *                       type: integer
+   */
   router.get('/', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
   router.get('/', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     async(req: AuthorizedRequest, res: ApiV3Response) => {
     async(req: AuthorizedRequest, res: ApiV3Response) => {
       const { query } = req;
       const { query } = req;
@@ -104,6 +167,36 @@ module.exports = (crowi: Crowi): Router => {
       }
       }
     });
     });
 
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/ancestors:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: getAncestors
+   *         summary: /external-user-groups/ancestors
+   *         parameters:
+   *           - name: groupId
+   *             in: query
+   *             required: true
+   *             schema:
+   *               type: string
+   *             description: The ID of the user group to get ancestors for
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved ancestor user groups
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     ancestorUserGroups:
+   *                       type: array
+   *                       items:
+   *                         type: object
+   */
   router.get('/ancestors', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
   router.get('/ancestors', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     validators.ancestorGroup, apiV3FormValidator,
     validators.ancestorGroup, apiV3FormValidator,
     async(req, res: ApiV3Response) => {
     async(req, res: ApiV3Response) => {
@@ -121,6 +214,46 @@ module.exports = (crowi: Crowi): Router => {
       }
       }
     });
     });
 
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/children:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: listChildren
+   *         summary: /external-user-groups/children
+   *         parameters:
+   *           - name: parentIds
+   *             in: query
+   *             schema:
+   *               type: array
+   *               items:
+   *                 type: string
+   *             description: The IDs of the parent user groups
+   *           - name: includeGrandChildren
+   *             in: query
+   *             schema:
+   *               type: boolean
+   *             description: Whether to include grandchild user groups
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved child user groups
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     childUserGroups:
+   *                       type: array
+   *                       items:
+   *                         type: object
+   *                     grandChildUserGroups:
+   *                       type: array
+   *                       items:
+   *                         type: object
+   */
   router.get('/children', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, validators.listChildren,
   router.get('/children', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, validators.listChildren,
     async(req, res) => {
     async(req, res) => {
       try {
       try {
@@ -139,6 +272,34 @@ module.exports = (crowi: Crowi): Router => {
       }
       }
     });
     });
 
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/{id}:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: getExternalUserGroup
+   *         summary: /external-user-groups/{id}
+   *         parameters:
+   *           - name: id
+   *             in: path
+   *             required: true
+   *             schema:
+   *               type: string
+   *             description: The ID of the external user group
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved external user group details
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     userGroup:
+   *                       type: object
+   */
   router.get('/:id', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, validators.detail,
   router.get('/:id', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, validators.detail,
     async(req, res: ApiV3Response) => {
     async(req, res: ApiV3Response) => {
       const { id } = req.params;
       const { id } = req.params;
@@ -154,6 +315,52 @@ module.exports = (crowi: Crowi): Router => {
       }
       }
     });
     });
 
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/{id}:
+   *       delete:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: deleteExternalUserGroup
+   *         summary: /external-user-groups/{id}
+   *         parameters:
+   *           - name: id
+   *             in: path
+   *             required: true
+   *             schema:
+   *               type: string
+   *             description: The ID of the external user group
+   *           - name: actionName
+   *             in: query
+   *             required: true
+   *             schema:
+   *               type: string
+   *             description: The action to perform on group delete
+   *           - name: transferToUserGroupId
+   *             in: query
+   *             schema:
+   *               type: string
+   *             description: The ID of the user group to transfer to
+   *           - name: transferToUserGroupType
+   *             in: query
+   *             schema:
+   *               type: string
+   *             description: The type of the user group to transfer to
+   *         responses:
+   *           200:
+   *             description: Successfully deleted the external user group
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     userGroups:
+   *                       type: array
+   *                       items:
+   *                         type: object
+   */
   router.delete('/:id', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
   router.delete('/:id', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     validators.delete, apiV3FormValidator, addActivity,
     validators.delete, apiV3FormValidator, addActivity,
     async(req: AuthorizedRequest, res: ApiV3Response) => {
     async(req: AuthorizedRequest, res: ApiV3Response) => {
@@ -184,6 +391,43 @@ module.exports = (crowi: Crowi): Router => {
       }
       }
     });
     });
 
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/{id}:
+   *       put:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: updateExternalUserGroup
+   *         summary: /external-user-groups/{id}
+   *         parameters:
+   *           - name: id
+   *             in: path
+   *             required: true
+   *             schema:
+   *               type: string
+   *             description: The ID of the external user group
+   *         requestBody:
+   *           required: true
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   description:
+   *                     type: string
+   *         responses:
+   *           200:
+   *             description: Successfully updated the external user group
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     userGroup:
+   *                       type: object
+   */
   router.put('/:id', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
   router.put('/:id', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     validators.update, apiV3FormValidator, addActivity,
     validators.update, apiV3FormValidator, addActivity,
     async(req, res: ApiV3Response) => {
     async(req, res: ApiV3Response) => {
@@ -207,6 +451,36 @@ module.exports = (crowi: Crowi): Router => {
       }
       }
     });
     });
 
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/:id/external-user-group-relations:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: getExternalUserGroupRelations
+   *         summary: /external-user-groups/:id/external-user-group-relations
+   *         parameters:
+   *           - name: id
+   *             in: path
+   *             required: true
+   *             schema:
+   *               type: string
+   *             description: The ID of the external user group
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved external user group relations
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     userGroupRelations:
+   *                       type: array
+   *                       items:
+   *                         type: object
+   */
   // TODO: add accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]) before loginRequiredStrictly
   // TODO: add accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]) before loginRequiredStrictly
   router.get('/:id/external-user-group-relations', loginRequiredStrictly, adminRequired,
   router.get('/:id/external-user-group-relations', loginRequiredStrictly, adminRequired,
     async(req, res: ApiV3Response) => {
     async(req, res: ApiV3Response) => {
@@ -226,6 +500,41 @@ module.exports = (crowi: Crowi): Router => {
       }
       }
     });
     });
 
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/ldap/sync-settings:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: getLdapSyncSettings
+   *         summary: Get LDAP sync settings
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved LDAP sync settings
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     ldapGroupSearchBase:
+   *                       type: string
+   *                     ldapGroupMembershipAttribute:
+   *                       type: string
+   *                     ldapGroupMembershipAttributeType:
+   *                       type: string
+   *                     ldapGroupChildGroupAttribute:
+   *                       type: string
+   *                     autoGenerateUserOnLdapGroupSync:
+   *                       type: boolean
+   *                     preserveDeletedLdapGroups:
+   *                       type: boolean
+   *                     ldapGroupNameAttribute:
+   *                       type: string
+   *                     ldapGroupDescriptionAttribute:
+   *                       type: string
+   */
   router.get('/ldap/sync-settings', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
   router.get('/ldap/sync-settings', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     (req: AuthorizedRequest, res: ApiV3Response) => {
     (req: AuthorizedRequest, res: ApiV3Response) => {
       const settings = {
       const settings = {
@@ -242,6 +551,41 @@ module.exports = (crowi: Crowi): Router => {
       return res.apiv3(settings);
       return res.apiv3(settings);
     });
     });
 
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/keycloak/sync-settings:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: getKeycloakSyncSettings
+   *         summary: Get Keycloak sync settings
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved Keycloak sync settings
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     keycloakHost:
+   *                       type: string
+   *                     keycloakGroupRealm:
+   *                       type: string
+   *                     keycloakGroupSyncClientRealm:
+   *                       type: string
+   *                     keycloakGroupSyncClientID:
+   *                       type: string
+   *                     keycloakGroupSyncClientSecret:
+   *                       type: string
+   *                     autoGenerateUserOnKeycloakGroupSync:
+   *                       type: boolean
+   *                     preserveDeletedKeycloakGroups:
+   *                       type: boolean
+   *                     keycloakGroupDescriptionAttribute:
+   *                       type: string
+   */
   router.get('/keycloak/sync-settings', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
   router.get('/keycloak/sync-settings', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     (req: AuthorizedRequest, res: ApiV3Response) => {
     (req: AuthorizedRequest, res: ApiV3Response) => {
       const settings = {
       const settings = {
@@ -258,6 +602,47 @@ module.exports = (crowi: Crowi): Router => {
       return res.apiv3(settings);
       return res.apiv3(settings);
     });
     });
 
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/ldap/sync-settings:
+   *       put:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: updateLdapSyncSettings
+   *         summary: Update LDAP sync settings
+   *         requestBody:
+   *           required: true
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   ldapGroupSearchBase:
+   *                     type: string
+   *                   ldapGroupMembershipAttribute:
+   *                     type: string
+   *                   ldapGroupMembershipAttributeType:
+   *                     type: string
+   *                   ldapGroupChildGroupAttribute:
+   *                     type: string
+   *                   autoGenerateUserOnLdapGroupSync:
+   *                     type: boolean
+   *                   preserveDeletedLdapGroups:
+   *                     type: boolean
+   *                   ldapGroupNameAttribute:
+   *                     type: string
+   *                   ldapGroupDescriptionAttribute:
+   *                     type: string
+   *         responses:
+   *           204:
+   *             description: Sync settings updated successfully
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   */
   router.put('/ldap/sync-settings', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
   router.put('/ldap/sync-settings', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     validators.ldapSyncSettings,
     validators.ldapSyncSettings,
     async(req: AuthorizedRequest, res: ApiV3Response) => {
     async(req: AuthorizedRequest, res: ApiV3Response) => {
@@ -296,6 +681,47 @@ module.exports = (crowi: Crowi): Router => {
       }
       }
     });
     });
 
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/keycloak/sync-settings:
+   *       put:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: updateKeycloakSyncSettings
+   *         summary: /external-user-groups/keycloak/sync-settings
+   *         requestBody:
+   *           required: true
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   keycloakHost:
+   *                     type: string
+   *                   keycloakGroupRealm:
+   *                     type: string
+   *                   keycloakGroupSyncClientRealm:
+   *                     type: string
+   *                   keycloakGroupSyncClientID:
+   *                     type: string
+   *                   keycloakGroupSyncClientSecret:
+   *                     type: string
+   *                   autoGenerateUserOnKeycloakGroupSync:
+   *                     type: boolean
+   *                   preserveDeletedKeycloakGroups:
+   *                     type: boolean
+   *                   keycloakGroupDescriptionAttribute:
+   *                     type: string
+   *         responses:
+   *           204:
+   *             description: Sync settings updated successfully
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   */
   router.put('/keycloak/sync-settings', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
   router.put('/keycloak/sync-settings', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     validators.keycloakSyncSettings,
     validators.keycloakSyncSettings,
     async(req: AuthorizedRequest, res: ApiV3Response) => {
     async(req: AuthorizedRequest, res: ApiV3Response) => {
@@ -329,6 +755,24 @@ module.exports = (crowi: Crowi): Router => {
       }
       }
     });
     });
 
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/ldap/sync:
+   *       put:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: syncExternalUserGroupsLdap
+   *         summary: Start LDAP sync process
+   *         responses:
+   *           202:
+   *             description: Sync process started
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   */
   router.put('/ldap/sync', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
   router.put('/ldap/sync', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     async(req: AuthorizedRequest, res: ApiV3Response) => {
     async(req: AuthorizedRequest, res: ApiV3Response) => {
       if (isExecutingSync()) {
       if (isExecutingSync()) {
@@ -359,6 +803,24 @@ module.exports = (crowi: Crowi): Router => {
       return res.apiv3({}, 202);
       return res.apiv3({}, 202);
     });
     });
 
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/keycloak/sync:
+   *       put:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: syncExternalUserGroupsKeycloak
+   *         summary: /external-user-groups/keycloak/sync
+   *         responses:
+   *           202:
+   *             description: Sync process started
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                 type: object
+   */
   router.put('/keycloak/sync', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
   router.put('/keycloak/sync', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     async(req: AuthorizedRequest, res: ApiV3Response) => {
     async(req: AuthorizedRequest, res: ApiV3Response) => {
       if (isExecutingSync()) {
       if (isExecutingSync()) {
@@ -405,12 +867,48 @@ module.exports = (crowi: Crowi): Router => {
       return res.apiv3({}, 202);
       return res.apiv3({}, 202);
     });
     });
 
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/ldap/sync-status:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: getExternalUserGroupsLdapSyncStatus
+   *         summary: Get LDAP sync status
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved LDAP sync status
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   $ref: '#/components/schemas/SyncStatus'
+   */
   router.get('/ldap/sync-status', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
   router.get('/ldap/sync-status', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     (req: AuthorizedRequest, res: ApiV3Response) => {
     (req: AuthorizedRequest, res: ApiV3Response) => {
       const syncStatus = crowi.ldapUserGroupSyncService?.syncStatus;
       const syncStatus = crowi.ldapUserGroupSyncService?.syncStatus;
       return res.apiv3({ ...syncStatus });
       return res.apiv3({ ...syncStatus });
     });
     });
 
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/ldap/sync-status:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: getExternalUserGroupsLdapSyncStatus
+   *         summary: /external-user-groups/ldap/sync-status
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved LDAP sync status
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   $ref: '#/components/schemas/SyncStatus'
+   */
   router.get('/keycloak/sync-status', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
   router.get('/keycloak/sync-status', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     (req: AuthorizedRequest, res: ApiV3Response) => {
     (req: AuthorizedRequest, res: ApiV3Response) => {
       const syncStatus = crowi.keycloakUserGroupSyncService?.syncStatus;
       const syncStatus = crowi.keycloakUserGroupSyncService?.syncStatus;

+ 3 - 3
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx

@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
 import { Modal, TabContent, TabPane } from 'reactstrap';
 import { Modal, TabContent, TabPane } from 'reactstrap';
 
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
+import type { UpsertAiAssistantData } from '~/features/openai/interfaces/ai-assistant';
 import { AiAssistantAccessScope, AiAssistantShareScope } from '~/features/openai/interfaces/ai-assistant';
 import { AiAssistantAccessScope, AiAssistantShareScope } from '~/features/openai/interfaces/ai-assistant';
 import type { IPagePathWithDescendantCount, IPageForItem } from '~/interfaces/page';
 import type { IPagePathWithDescendantCount, IPageForItem } from '~/interfaces/page';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
@@ -131,7 +132,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
         ? convertToGrantedGroups(selectedUserGroupsForAccessScope)
         ? convertToGrantedGroups(selectedUserGroupsForAccessScope)
         : undefined;
         : undefined;
 
 
-      const reqBody = {
+      const reqBody: UpsertAiAssistantData = {
         name,
         name,
         description,
         description,
         additionalInstruction: instruction,
         additionalInstruction: instruction,
@@ -140,7 +141,6 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
         accessScope: selectedAccessScope,
         accessScope: selectedAccessScope,
         grantedGroupsForShareScope,
         grantedGroupsForShareScope,
         grantedGroupsForAccessScope,
         grantedGroupsForAccessScope,
-        isDefault: shouldEdit ? aiAssistant.isDefault : false,
       };
       };
 
 
       if (shouldEdit) {
       if (shouldEdit) {
@@ -159,7 +159,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
       logger.error(err);
       logger.error(err);
     }
     }
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  }, [selectedPages, selectedShareScope, selectedUserGroupsForShareScope, selectedAccessScope, selectedUserGroupsForAccessScope, name, description, instruction, shouldEdit, aiAssistant?.isDefault, aiAssistant?._id, mutateAiAssistants, closeAiAssistantManagementModal]);
+  }, [t, selectedPages, selectedShareScope, selectedUserGroupsForShareScope, selectedAccessScope, selectedUserGroupsForAccessScope, name, description, instruction, shouldEdit, aiAssistant?._id, mutateAiAssistants, closeAiAssistantManagementModal]);
 
 
 
 
   /*
   /*

+ 1 - 1
apps/app/src/features/openai/interfaces/ai-assistant.ts

@@ -42,7 +42,7 @@ export interface AiAssistant {
 
 
 export type AiAssistantHasId = AiAssistant & HasObjectId
 export type AiAssistantHasId = AiAssistant & HasObjectId
 
 
-export type UpsertAiAssistantData = Omit<AiAssistant, 'owner' | 'vectorStore'>
+export type UpsertAiAssistantData = Omit<AiAssistant, 'owner' | 'vectorStore' | 'isDefault'>
 
 
 export type AccessibleAiAssistants = {
 export type AccessibleAiAssistants = {
   myAiAssistants: AiAssistant[],
   myAiAssistants: AiAssistant[],

+ 25 - 9
apps/app/src/features/openai/server/models/ai-assistant.ts

@@ -1,5 +1,4 @@
 import { type IGrantedGroup, GroupType } from '@growi/core';
 import { type IGrantedGroup, GroupType } from '@growi/core';
-import createError from 'http-errors';
 import { type Model, type Document, Schema } from 'mongoose';
 import { type Model, type Document, Schema } from 'mongoose';
 
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
@@ -112,16 +111,33 @@ const schema = new Schema<AiAssistantDocument>(
 
 
 
 
 schema.statics.setDefault = async function(id: string, isDefault: boolean): Promise<AiAssistantDocument> {
 schema.statics.setDefault = async function(id: string, isDefault: boolean): Promise<AiAssistantDocument> {
-  const aiAssistant = await this.findOne({ _id: id, shareScope: AiAssistantAccessScope.PUBLIC_ONLY });
-  if (aiAssistant == null) {
-    throw createError(404, 'AiAssistant document does not exist');
+  if (isDefault) {
+    await this.bulkWrite([
+      {
+        updateOne: {
+          filter: {
+            _id: id,
+            shareScope:  AiAssistantShareScope.PUBLIC_ONLY,
+          },
+          update: { $set: { isDefault: true } },
+        },
+      },
+      {
+        updateMany: {
+          filter: {
+            _id: { $ne: id },
+            isDefault: true,
+          },
+          update: { $set: { isDefault: false } },
+        },
+      },
+    ]);
+  }
+  else {
+    await this.findByIdAndUpdate(id, { isDefault: false });
   }
   }
 
 
-  await this.updateMany({ isDefault: true }, { isDefault: false });
-
-  aiAssistant.isDefault = isDefault;
-  const updatedAiAssistant = await aiAssistant.save();
-
+  const updatedAiAssistant = await this.findById(id);
   return updatedAiAssistant;
   return updatedAiAssistant;
 };
 };
 
 

+ 1 - 1
apps/app/src/features/openai/server/routes/ai-assistant.ts

@@ -44,7 +44,7 @@ export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
           return res.apiv3Err(new ErrorV3('The number of learnable pages exceeds the limit'), 400);
           return res.apiv3Err(new ErrorV3('The number of learnable pages exceeds the limit'), 400);
         }
         }
 
 
-        const aiAssistant = await openaiService.createAiAssistant(aiAssistantData);
+        const aiAssistant = await openaiService.createAiAssistant(req.body, req.user);
 
 
         return res.apiv3({ aiAssistant });
         return res.apiv3({ aiAssistant });
       }
       }

+ 2 - 4
apps/app/src/features/openai/server/routes/update-ai-assistant.ts

@@ -51,14 +51,12 @@ export const updateAiAssistantsFactory: UpdateAiAssistantsFactory = (crowi) => {
       }
       }
 
 
       try {
       try {
-        const aiAssistantData = { ...req.body, owner: user._id };
-
-        const isLearnablePageLimitExceeded = await openaiService.isLearnablePageLimitExceeded(user, aiAssistantData.pagePathPatterns);
+        const isLearnablePageLimitExceeded = await openaiService.isLearnablePageLimitExceeded(user, req.body.pagePathPatterns);
         if (isLearnablePageLimitExceeded) {
         if (isLearnablePageLimitExceeded) {
           return res.apiv3Err(new ErrorV3('The number of learnable pages exceeds the limit'), 400);
           return res.apiv3Err(new ErrorV3('The number of learnable pages exceeds the limit'), 400);
         }
         }
 
 
-        const updatedAiAssistant = await openaiService.updateAiAssistant(id, aiAssistantData);
+        const updatedAiAssistant = await openaiService.updateAiAssistant(id, req.body, user);
 
 
         return res.apiv3({ updatedAiAssistant });
         return res.apiv3({ updatedAiAssistant });
       }
       }

+ 16 - 11
apps/app/src/features/openai/server/services/openai.ts

@@ -29,6 +29,7 @@ import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { OpenaiServiceTypes } from '../../interfaces/ai';
 import { OpenaiServiceTypes } from '../../interfaces/ai';
+import type { UpsertAiAssistantData } from '../../interfaces/ai-assistant';
 import {
 import {
   type AccessibleAiAssistants, type AiAssistant, AiAssistantAccessScope, AiAssistantShareScope,
   type AccessibleAiAssistants, type AiAssistant, AiAssistantAccessScope, AiAssistantShareScope,
 } from '../../interfaces/ai-assistant';
 } from '../../interfaces/ai-assistant';
@@ -79,8 +80,8 @@ export interface IOpenaiService {
   deleteVectorStoreFilesByPageIds(pageIds: Types.ObjectId[]): Promise<void>;
   deleteVectorStoreFilesByPageIds(pageIds: Types.ObjectId[]): Promise<void>;
   deleteObsoleteVectorStoreFile(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
   deleteObsoleteVectorStoreFile(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
   isAiAssistantUsable(aiAssistantId: string, user: IUserHasId): Promise<boolean>;
   isAiAssistantUsable(aiAssistantId: string, user: IUserHasId): Promise<boolean>;
-  createAiAssistant(data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument>;
-  updateAiAssistant(aiAssistantId: string, data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument>;
+  createAiAssistant(data: UpsertAiAssistantData, user: IUserHasId): Promise<AiAssistantDocument>;
+  updateAiAssistant(aiAssistantId: string, data: UpsertAiAssistantData, user: IUserHasId): Promise<AiAssistantDocument>;
   getAccessibleAiAssistants(user: IUserHasId): Promise<AccessibleAiAssistants>
   getAccessibleAiAssistants(user: IUserHasId): Promise<AccessibleAiAssistants>
   isLearnablePageLimitExceeded(user: IUserHasId, pagePathPatterns: string[]): Promise<boolean>;
   isLearnablePageLimitExceeded(user: IUserHasId, pagePathPatterns: string[]): Promise<boolean>;
 }
 }
@@ -758,9 +759,9 @@ class OpenaiService implements IOpenaiService {
     return false;
     return false;
   }
   }
 
 
-  async createAiAssistant(data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument> {
+  async createAiAssistant(data: UpsertAiAssistantData, user: IUserHasId): Promise<AiAssistantDocument> {
     await this.validateGrantedUserGroupsForAiAssistant(
     await this.validateGrantedUserGroupsForAiAssistant(
-      data.owner,
+      user,
       data.shareScope,
       data.shareScope,
       data.accessScope,
       data.accessScope,
       data.grantedGroupsForShareScope,
       data.grantedGroupsForShareScope,
@@ -768,7 +769,7 @@ class OpenaiService implements IOpenaiService {
     );
     );
 
 
     const conditions = await this.createConditionForCreateVectorStoreFile(
     const conditions = await this.createConditionForCreateVectorStoreFile(
-      data.owner,
+      user,
       data.accessScope,
       data.accessScope,
       data.grantedGroupsForAccessScope,
       data.grantedGroupsForAccessScope,
       data.pagePathPatterns,
       data.pagePathPatterns,
@@ -776,7 +777,7 @@ class OpenaiService implements IOpenaiService {
 
 
     const vectorStoreRelation = await this.createVectorStore(data.name);
     const vectorStoreRelation = await this.createVectorStore(data.name);
     const aiAssistant = await AiAssistantModel.create({
     const aiAssistant = await AiAssistantModel.create({
-      ...data, vectorStore: vectorStoreRelation,
+      ...data, owner: user, vectorStore: vectorStoreRelation,
     });
     });
 
 
     // VectorStore creation process does not await
     // VectorStore creation process does not await
@@ -785,14 +786,14 @@ class OpenaiService implements IOpenaiService {
     return aiAssistant;
     return aiAssistant;
   }
   }
 
 
-  async updateAiAssistant(aiAssistantId: string, data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument> {
-    const aiAssistant = await AiAssistantModel.findOne({ owner: data.owner, _id: aiAssistantId });
+  async updateAiAssistant(aiAssistantId: string, data: UpsertAiAssistantData, user: IUserHasId): Promise<AiAssistantDocument> {
+    const aiAssistant = await AiAssistantModel.findOne({ owner: user, _id: aiAssistantId });
     if (aiAssistant == null) {
     if (aiAssistant == null) {
       throw createError(404, 'AiAssistant document does not exist');
       throw createError(404, 'AiAssistant document does not exist');
     }
     }
 
 
     await this.validateGrantedUserGroupsForAiAssistant(
     await this.validateGrantedUserGroupsForAiAssistant(
-      data.owner,
+      user,
       data.shareScope,
       data.shareScope,
       data.accessScope,
       data.accessScope,
       data.grantedGroupsForShareScope,
       data.grantedGroupsForShareScope,
@@ -810,7 +811,7 @@ class OpenaiService implements IOpenaiService {
     let newVectorStoreRelation: VectorStoreDocument | undefined;
     let newVectorStoreRelation: VectorStoreDocument | undefined;
     if (shouldRebuildVectorStore) {
     if (shouldRebuildVectorStore) {
       const conditions = await this.createConditionForCreateVectorStoreFile(
       const conditions = await this.createConditionForCreateVectorStoreFile(
-        data.owner,
+        user,
         data.accessScope,
         data.accessScope,
         data.grantedGroupsForAccessScope,
         data.grantedGroupsForAccessScope,
         data.pagePathPatterns,
         data.pagePathPatterns,
@@ -834,7 +835,11 @@ class OpenaiService implements IOpenaiService {
     };
     };
 
 
     aiAssistant.set({ ...newData });
     aiAssistant.set({ ...newData });
-    const updatedAiAssistant = await aiAssistant.save();
+    let updatedAiAssistant: AiAssistantDocument = await aiAssistant.save();
+
+    if (data.shareScope !== AiAssistantShareScope.PUBLIC_ONLY && aiAssistant.isDefault) {
+      updatedAiAssistant = await AiAssistantModel.setDefault(aiAssistant._id, false);
+    }
 
 
     return updatedAiAssistant;
     return updatedAiAssistant;
   }
   }

+ 119 - 0
apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx

@@ -0,0 +1,119 @@
+import { useState } from 'react';
+
+import { format } from 'date-fns/format';
+import { useTranslation } from 'next-i18next';
+import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { usePageBulkExportSelectModal } from '~/features/page-bulk-export/client/stores/modal';
+import { PageBulkExportFormat } from '~/features/page-bulk-export/interfaces/page-bulk-export';
+import { useIsPdfBulkExportEnabled } from '~/stores-universal/context';
+import { useCurrentPagePath } from '~/stores/page';
+
+const PageBulkExportSelectModal = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: status, close } = usePageBulkExportSelectModal();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: isPdfBulkExportEnabled } = useIsPdfBulkExportEnabled();
+
+  const [isRestartModalOpened, setIsRestartModalOpened] = useState(false);
+  const [formatMemoForRestart, setFormatMemoForRestart] = useState<PageBulkExportFormat | undefined>(undefined);
+  const [duplicateJobInfo, setDuplicateJobInfo] = useState<{createdAt: string} | undefined>(undefined);
+
+  const startBulkExport = async(format: PageBulkExportFormat) => {
+    try {
+      setFormatMemoForRestart(format);
+      await apiv3Post('/page-bulk-export', { path: currentPagePath, format });
+      toastSuccess(t('page_export.bulk_export_started'));
+    }
+    catch (e) {
+      const errorCode = e?.[0].code ?? 'page_export.failed_to_export';
+      if (errorCode === 'page_export.duplicate_bulk_export_job_error') {
+        setDuplicateJobInfo(e[0].args.duplicateJob);
+        setIsRestartModalOpened(true);
+      }
+      else {
+        toastError(t(errorCode));
+      }
+    }
+    close();
+  };
+
+  const restartBulkExport = async() => {
+    if (formatMemoForRestart != null) {
+      try {
+        await apiv3Post('/page-bulk-export', { path: currentPagePath, format: formatMemoForRestart, restartJob: true });
+        toastSuccess(t('page_export.bulk_export_started'));
+      }
+      catch (e) {
+        toastError(t('page_export.failed_to_export'));
+      }
+      setIsRestartModalOpened(false);
+    }
+  };
+
+  return (
+    <>
+      {status != null && (
+        <Modal isOpen={status.isOpened} toggle={close} size="lg">
+          <ModalHeader tag="h4" toggle={close}>
+            {t('page_export.bulk_export')}
+          </ModalHeader>
+          <ModalBody>
+            <p className="card custom-card bg-warning-subtle pt-3 px-3">
+              {t('page_export.bulk_export_download_explanation')}
+              <span className="mt-3"><span className="material-symbols-outlined me-1">warning</span>{t('Warning')}</span>
+              <ul className="mt-2">
+                <li>{t('page_export.bulk_export_exec_time_warning')}</li>
+                <li>{t('page_export.large_bulk_export_warning')}</li>
+              </ul>
+            </p>
+            {t('page_export.choose_export_format')}:
+            <div className="d-flex justify-content-center mt-3">
+              <button className="btn btn-primary" type="button" onClick={() => startBulkExport(PageBulkExportFormat.md)}>
+                {t('page_export.markdown')}
+              </button>
+              {isPdfBulkExportEnabled && (
+                <button className="btn btn-primary ms-2" type="button" onClick={() => startBulkExport(PageBulkExportFormat.pdf)}>
+                  PDF
+                </button>
+              )}
+            </div>
+          </ModalBody>
+        </Modal>
+      )}
+
+      <Modal isOpen={isRestartModalOpened} toggle={() => setIsRestartModalOpened(false)}>
+        <ModalHeader tag="h4" toggle={() => setIsRestartModalOpened(false)}>
+          {t('page_export.export_in_progress')}
+        </ModalHeader>
+        <ModalBody>
+          {t('page_export.export_in_progress_explanation')}
+          <div className="text-danger">
+            {t('page_export.export_cancel_warning')}:
+          </div>
+          { duplicateJobInfo && (
+            <div className="my-1">
+              <ul>
+                { formatMemoForRestart && (
+                  <li>
+                    {t('page_export.format')}: {formatMemoForRestart === PageBulkExportFormat.md ? t('page_export.markdown') : 'PDF'}
+                  </li>
+                )}
+                <li>{t('page_export.started_on')}: {format(new Date(duplicateJobInfo.createdAt), 'MM/dd HH:mm')}</li>
+              </ul>
+            </div>
+          )}
+          <div className="d-flex justify-content-center mt-3">
+            <button className="btn btn-primary" type="button" onClick={() => restartBulkExport()}>
+              {t('page_export.restart')}
+            </button>
+          </div>
+        </ModalBody>
+      </Modal>
+    </>
+  );
+};
+
+export default PageBulkExportSelectModal;

+ 27 - 0
apps/app/src/features/page-bulk-export/client/stores/modal.tsx

@@ -0,0 +1,27 @@
+import { SWRResponse } from 'swr';
+
+import { useStaticSWR } from '../../../../stores/use-static-swr';
+
+type PageBulkExportSelectModalStatus = {
+  isOpened: boolean,
+}
+
+type PageBulkExportSelectModalUtils = {
+  open(): Promise<void>,
+  close(): Promise<void>,
+}
+
+export const usePageBulkExportSelectModal = (): SWRResponse<PageBulkExportSelectModalStatus, Error> & PageBulkExportSelectModalUtils => {
+  const initialStatus: PageBulkExportSelectModalStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<PageBulkExportSelectModalStatus, Error>('pageBulkExportSelectModal', undefined, { fallbackData: initialStatus });
+
+  return {
+    ...swrResponse,
+    async open() {
+      await swrResponse.mutate({ isOpened: true });
+    },
+    async close() {
+      await swrResponse.mutate({ isOpened: false });
+    },
+  };
+};

+ 49 - 0
apps/app/src/features/page-bulk-export/interfaces/page-bulk-export.ts

@@ -0,0 +1,49 @@
+import type {
+  HasObjectId,
+  IAttachment, IPage, IRevision, IUser, Ref,
+} from '@growi/core';
+
+export const PageBulkExportFormat = {
+  md: 'md',
+  pdf: 'pdf',
+} as const;
+
+export type PageBulkExportFormat = typeof PageBulkExportFormat[keyof typeof PageBulkExportFormat]
+
+export const PageBulkExportJobInProgressStatus = {
+  initializing: 'initializing', // preparing for export
+  exporting: 'exporting', // exporting to fs
+  uploading: 'uploading', // uploading to cloud storage
+} as const;
+
+export const PageBulkExportJobStatus = {
+  ...PageBulkExportJobInProgressStatus,
+  completed: 'completed',
+  failed: 'failed',
+} as const;
+
+export type PageBulkExportJobStatus = typeof PageBulkExportJobStatus[keyof typeof PageBulkExportJobStatus]
+
+export interface IPageBulkExportJob {
+  user: Ref<IUser>, // user that started export job
+  page: Ref<IPage>, // the root page of page tree to export
+  lastExportedPagePath?: string, // the path of page that was exported to the fs last
+  format: PageBulkExportFormat,
+  completedAt?: Date, // the date at which job was completed
+  attachment?: Ref<IAttachment>,
+  status: PageBulkExportJobStatus,
+  statusOnPreviousCronExec?: PageBulkExportJobStatus, // status on previous cron execution
+  revisionListHash?: string, // Hash created from the list of revision IDs. Used to detect existing duplicate uploads.
+  restartFlag: boolean, // flag to restart the job
+  createdAt?: Date,
+  updatedAt?: Date
+}
+
+export interface IPageBulkExportJobHasId extends IPageBulkExportJob, HasObjectId {}
+
+// snapshot of page info to upload
+export interface IPageBulkExportPageSnapshot {
+  pageBulkExportJob: Ref<IPageBulkExportJob>,
+  path: string, // page path when export was stared
+  revision: Ref<IRevision>, // page revision when export was stared
+}

+ 29 - 0
apps/app/src/features/page-bulk-export/server/models/page-bulk-export-job.ts

@@ -0,0 +1,29 @@
+import { type Document, type Model, Schema } from 'mongoose';
+
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import type { IPageBulkExportJob } from '../../interfaces/page-bulk-export';
+import { PageBulkExportFormat, PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
+
+export interface PageBulkExportJobDocument extends IPageBulkExportJob, Document {}
+
+export type PageBulkExportJobModel = Model<PageBulkExportJobDocument>
+
+const pageBulkExportJobSchema = new Schema<PageBulkExportJobDocument>({
+  user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
+  page: { type: Schema.Types.ObjectId, ref: 'Page', required: true },
+  lastExportedPagePath: { type: String },
+  format: { type: String, enum: Object.values(PageBulkExportFormat), required: true },
+  completedAt: { type: Date },
+  attachment: { type: Schema.Types.ObjectId, ref: 'Attachment' },
+  status: {
+    type: String, enum: Object.values(PageBulkExportJobStatus), required: true, default: PageBulkExportJobStatus.initializing,
+  },
+  statusOnPreviousCronExec: {
+    type: String, enum: Object.values(PageBulkExportJobStatus),
+  },
+  restartFlag: { type: Boolean, required: true, default: false },
+  revisionListHash: { type: String },
+}, { timestamps: true });
+
+export default getOrCreateModel<PageBulkExportJobDocument, PageBulkExportJobModel>('PageBulkExportJob', pageBulkExportJobSchema);

+ 19 - 0
apps/app/src/features/page-bulk-export/server/models/page-bulk-export-page-snapshot.ts

@@ -0,0 +1,19 @@
+import { type Document, type Model, Schema } from 'mongoose';
+
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import type { IPageBulkExportPageSnapshot } from '../../interfaces/page-bulk-export';
+
+export interface PageBulkExportPageSnapshotDocument extends IPageBulkExportPageSnapshot, Document {}
+
+export type PageBulkExportPageSnapshotModel = Model<PageBulkExportPageSnapshotDocument>
+
+const pageBulkExportPageInfoSchema = new Schema<PageBulkExportPageSnapshotDocument>({
+  pageBulkExportJob: { type: Schema.Types.ObjectId, ref: 'PageBulkExportJob', required: true },
+  path: { type: String, required: true },
+  revision: { type: Schema.Types.ObjectId, ref: 'Revision', required: true },
+}, { timestamps: true });
+
+export default getOrCreateModel<PageBulkExportPageSnapshotDocument, PageBulkExportPageSnapshotModel>(
+  'PageBulkExportPageSnapshot', pageBulkExportPageInfoSchema,
+);

+ 58 - 0
apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts

@@ -0,0 +1,58 @@
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request } from 'express';
+import { Router } from 'express';
+import { body, validationResult } from 'express-validator';
+
+import type Crowi from '~/server/crowi';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import loggerFactory from '~/utils/logger';
+
+import { pageBulkExportService, DuplicateBulkExportJobError } from '../../service/page-bulk-export';
+
+const logger = loggerFactory('growi:routes:apiv3:page-bulk-export');
+
+const router = Router();
+
+interface AuthorizedRequest extends Request {
+  user?: any
+}
+
+module.exports = (crowi: Crowi): Router => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  const validators = {
+    pageBulkExport: [
+      body('path').exists({ checkFalsy: true }).isString(),
+      body('format').exists({ checkFalsy: true }).isString(),
+      body('restartJob').isBoolean().optional(),
+    ],
+  };
+
+  router.post('/', loginRequiredStrictly, validators.pageBulkExport, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const errors = validationResult(req);
+    if (!errors.isEmpty()) {
+      return res.status(400).json({ errors: errors.array() });
+    }
+
+    const { path, format, restartJob } = req.body;
+
+    try {
+      await pageBulkExportService?.createOrResetBulkExportJob(path, format, req.user, restartJob);
+      return res.apiv3({}, 204);
+    }
+    catch (err) {
+      logger.error(err);
+      if (err instanceof DuplicateBulkExportJobError) {
+        return res.apiv3Err(new ErrorV3(
+          'Duplicate bulk export job is in progress',
+          'page_export.duplicate_bulk_export_job_error', undefined,
+          { duplicateJob: { createdAt: err.duplicateJob.createdAt } },
+        ), 409);
+      }
+      return res.apiv3Err(new ErrorV3('Failed to start bulk export', 'page_export.failed_to_export'));
+    }
+  });
+
+  return router;
+
+};

+ 42 - 0
apps/app/src/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron.ts

@@ -0,0 +1,42 @@
+import { configManager } from '~/server/service/config-manager';
+import CronService from '~/server/service/cron';
+import loggerFactory from '~/utils/logger';
+
+import { PageBulkExportJobInProgressStatus } from '../../interfaces/page-bulk-export';
+import PageBulkExportJob from '../models/page-bulk-export-job';
+
+import { pageBulkExportJobCronService } from './page-bulk-export-job-cron';
+
+const logger = loggerFactory('growi:service:check-page-bulk-export-job-in-progress-cron');
+
+/**
+ * Manages cronjob which checks if PageBulkExportJob in progress exists.
+ * If it does, and PageBulkExportJobCronService is not running, start PageBulkExportJobCronService
+ */
+class CheckPageBulkExportJobInProgressCronService extends CronService {
+
+  override getCronSchedule(): string {
+    return configManager.getConfig('app:checkPageBulkExportJobInProgressCronSchedule');
+  }
+
+  override async executeJob(): Promise<void> {
+    // TODO: remove growiCloudUri condition when bulk export can be relased for GROWI.cloud (https://redmine.weseek.co.jp/issues/163220)
+    const isBulkExportPagesEnabled = configManager.getConfig('app:isBulkExportPagesEnabled') && configManager.getConfig('app:growiCloudUri') == null;
+    if (!isBulkExportPagesEnabled) return;
+
+    const pageBulkExportJobInProgress = await PageBulkExportJob.findOne({
+      $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
+    });
+    const pageBulkExportInProgressExists = pageBulkExportJobInProgress != null;
+
+    if (pageBulkExportInProgressExists && !pageBulkExportJobCronService?.isJobRunning()) {
+      pageBulkExportJobCronService?.startCron();
+    }
+    else if (!pageBulkExportInProgressExists) {
+      pageBulkExportJobCronService?.stopCron();
+    }
+  }
+
+}
+
+export const checkPageBulkExportJobInProgressCronService = new CheckPageBulkExportJobInProgressCronService(); // singleton instance

+ 180 - 0
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.integ.ts

@@ -0,0 +1,180 @@
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import { configManager } from '~/server/service/config-manager';
+
+import { PageBulkExportFormat, PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
+import PageBulkExportJob from '../models/page-bulk-export-job';
+
+import instanciatePageBulkExportJobCleanUpCronService, { pageBulkExportJobCleanUpCronService } from './page-bulk-export-job-clean-up-cron';
+
+// TODO: use actual user model after ~/server/models/user.js becomes importable in vitest
+// ref: https://github.com/vitest-dev/vitest/issues/846
+const userSchema = new mongoose.Schema({
+  name: { type: String },
+  username: { type: String, required: true, unique: true },
+  email: { type: String, unique: true, sparse: true },
+}, {
+  timestamps: true,
+});
+const User = mongoose.model('User', userSchema);
+
+vi.mock('./page-bulk-export-job-cron', () => {
+  return {
+    pageBulkExportJobCronService: {
+      cleanUpExportJobResources: vi.fn(() => Promise.resolve()),
+    },
+  };
+});
+
+describe('PageBulkExportJobCleanUpCronService', () => {
+  const crowi = {} as Crowi;
+  let user;
+
+  beforeAll(async() => {
+    await configManager.loadConfigs();
+    user = await User.create({
+      name: 'Example for PageBulkExportJobCleanUpCronService Test',
+      username: 'page bulk export job cleanup cron test user',
+      email: 'bulkExportCleanUpCronTestUser@example.com',
+    });
+    instanciatePageBulkExportJobCleanUpCronService(crowi);
+  });
+
+  beforeEach(async() => {
+    await PageBulkExportJob.deleteMany();
+  });
+
+  describe('deleteExpiredExportJobs', () => {
+    // arrange
+    const jobId1 = new mongoose.Types.ObjectId();
+    const jobId2 = new mongoose.Types.ObjectId();
+    const jobId3 = new mongoose.Types.ObjectId();
+    const jobId4 = new mongoose.Types.ObjectId();
+    beforeEach(async() => {
+      await configManager.updateConfig('app:bulkExportJobExpirationSeconds', 86400); // 1 day
+
+      await PageBulkExportJob.insertMany([
+        {
+          _id: jobId1,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.initializing,
+          createdAt: new Date(Date.now()),
+        },
+        {
+          _id: jobId2,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.exporting,
+          createdAt: new Date(Date.now() - 86400 * 1000 - 1),
+        },
+        {
+          _id: jobId3,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.uploading,
+          createdAt: new Date(Date.now() - 86400 * 1000 - 2),
+        },
+        {
+          _id: jobId4, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.failed,
+        },
+      ]);
+    });
+
+    test('should delete expired jobs', async() => {
+      expect(await PageBulkExportJob.find()).toHaveLength(4);
+
+      // act
+      await pageBulkExportJobCleanUpCronService?.deleteExpiredExportJobs();
+      const jobs = await PageBulkExportJob.find();
+
+      // assert
+      expect(jobs).toHaveLength(2);
+      expect(jobs.map(job => job._id).sort()).toStrictEqual([jobId1, jobId4].sort());
+    });
+  });
+
+  describe('deleteDownloadExpiredExportJobs', () => {
+    // arrange
+    const jobId1 = new mongoose.Types.ObjectId();
+    const jobId2 = new mongoose.Types.ObjectId();
+    const jobId3 = new mongoose.Types.ObjectId();
+    const jobId4 = new mongoose.Types.ObjectId();
+    beforeEach(async() => {
+      await configManager.updateConfig('app:bulkExportDownloadExpirationSeconds', 86400); // 1 day
+
+      await PageBulkExportJob.insertMany([
+        {
+          _id: jobId1,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.completed,
+          completedAt: new Date(Date.now()),
+        },
+        {
+          _id: jobId2,
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.completed,
+          completedAt: new Date(Date.now() - 86400 * 1000 - 1),
+        },
+        {
+          _id: jobId3, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.initializing,
+        },
+        {
+          _id: jobId4, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.failed,
+        },
+      ]);
+    });
+
+    test('should delete download expired jobs', async() => {
+      expect(await PageBulkExportJob.find()).toHaveLength(4);
+
+      // act
+      await pageBulkExportJobCleanUpCronService?.deleteDownloadExpiredExportJobs();
+      const jobs = await PageBulkExportJob.find();
+
+      // assert
+      expect(jobs).toHaveLength(3);
+      expect(jobs.map(job => job._id).sort()).toStrictEqual([jobId1, jobId3, jobId4].sort());
+    });
+  });
+
+  describe('deleteFailedExportJobs', () => {
+    // arrange
+    const jobId1 = new mongoose.Types.ObjectId();
+    const jobId2 = new mongoose.Types.ObjectId();
+    const jobId3 = new mongoose.Types.ObjectId();
+    beforeEach(async() => {
+      await PageBulkExportJob.insertMany([
+        {
+          _id: jobId1, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.failed,
+        },
+        {
+          _id: jobId2, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.initializing,
+        },
+        {
+          _id: jobId3, user, page: new mongoose.Types.ObjectId(), format: PageBulkExportFormat.md, status: PageBulkExportJobStatus.failed,
+        },
+      ]);
+    });
+
+    test('should delete failed export jobs', async() => {
+      expect(await PageBulkExportJob.find()).toHaveLength(3);
+
+      // act
+      await pageBulkExportJobCleanUpCronService?.deleteFailedExportJobs();
+      const jobs = await PageBulkExportJob.find();
+
+      // assert
+      expect(jobs).toHaveLength(1);
+      expect(jobs.map(job => job._id)).toStrictEqual([jobId2]);
+    });
+  });
+});

+ 118 - 0
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.ts

@@ -0,0 +1,118 @@
+import type { HydratedDocument } from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import { configManager } from '~/server/service/config-manager';
+import CronService from '~/server/service/cron';
+import loggerFactory from '~/utils/logger';
+
+import { PageBulkExportJobInProgressStatus, PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
+import type { PageBulkExportJobDocument } from '../models/page-bulk-export-job';
+import PageBulkExportJob from '../models/page-bulk-export-job';
+
+import { pageBulkExportJobCronService } from './page-bulk-export-job-cron';
+
+const logger = loggerFactory('growi:service:page-bulk-export-job-clean-up-cron');
+
+/**
+ * Manages cronjob which deletes unnecessary bulk export jobs
+ */
+class PageBulkExportJobCleanUpCronService extends CronService {
+
+  crowi: Crowi;
+
+  constructor(crowi: Crowi) {
+    super();
+    this.crowi = crowi;
+  }
+
+  override getCronSchedule(): string {
+    return configManager.getConfig('app:pageBulkExportJobCleanUpCronSchedule');
+  }
+
+  override async executeJob(): Promise<void> {
+    // Execute cleanup even if isBulkExportPagesEnabled is false, to cleanup jobs which were created before bulk export was disabled
+
+    await this.deleteExpiredExportJobs();
+    await this.deleteDownloadExpiredExportJobs();
+    await this.deleteFailedExportJobs();
+  }
+
+  /**
+   * Delete bulk export jobs which are on-going and has passed the limit time for execution
+   */
+  async deleteExpiredExportJobs() {
+    const exportJobExpirationSeconds = configManager.getConfig('app:bulkExportJobExpirationSeconds');
+    const expiredExportJobs = await PageBulkExportJob.find({
+      $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
+      createdAt: { $lt: new Date(Date.now() - exportJobExpirationSeconds * 1000) },
+    });
+
+    if (pageBulkExportJobCronService != null) {
+      await this.cleanUpAndDeleteBulkExportJobs(expiredExportJobs, pageBulkExportJobCronService.cleanUpExportJobResources.bind(pageBulkExportJobCronService));
+    }
+  }
+
+  /**
+   * Delete bulk export jobs which have completed but the due time for downloading has passed
+   */
+  async deleteDownloadExpiredExportJobs() {
+    const downloadExpirationSeconds = configManager.getConfig('app:bulkExportDownloadExpirationSeconds');
+    const thresholdDate = new Date(Date.now() - downloadExpirationSeconds * 1000);
+    const downloadExpiredExportJobs = await PageBulkExportJob.find({
+      status: PageBulkExportJobStatus.completed,
+      completedAt: { $lt: thresholdDate },
+    });
+
+    const cleanUp = async(job: PageBulkExportJobDocument) => {
+      await pageBulkExportJobCronService?.cleanUpExportJobResources(job);
+
+      const hasSameAttachmentAndDownloadNotExpired = await PageBulkExportJob.findOne({
+        attachment: job.attachment,
+        _id: { $ne: job._id },
+        completedAt: { $gte: thresholdDate },
+      });
+      if (hasSameAttachmentAndDownloadNotExpired == null) {
+        // delete attachment if no other export job (which download has not expired) has re-used it
+        await this.crowi.attachmentService?.removeAttachment(job.attachment);
+      }
+    };
+
+    await this.cleanUpAndDeleteBulkExportJobs(downloadExpiredExportJobs, cleanUp);
+  }
+
+  /**
+   * Delete bulk export jobs which have failed
+   */
+  async deleteFailedExportJobs() {
+    const failedExportJobs = await PageBulkExportJob.find({ status: PageBulkExportJobStatus.failed });
+
+    if (pageBulkExportJobCronService != null) {
+      await this.cleanUpAndDeleteBulkExportJobs(failedExportJobs, pageBulkExportJobCronService.cleanUpExportJobResources.bind(pageBulkExportJobCronService));
+    }
+  }
+
+  async cleanUpAndDeleteBulkExportJobs(
+      pageBulkExportJobs: HydratedDocument<PageBulkExportJobDocument>[],
+      cleanUp: (job: PageBulkExportJobDocument) => Promise<void>,
+  ): Promise<void> {
+    const results = await Promise.allSettled(pageBulkExportJobs.map(job => cleanUp(job)));
+    results.forEach((result) => {
+      if (result.status === 'rejected') logger.error(result.reason);
+    });
+
+    // Only batch delete jobs which have been successfully cleaned up
+    // Clean up failed jobs will be retried in the next cron execution
+    const cleanedUpJobs = pageBulkExportJobs.filter((_, index) => results[index].status === 'fulfilled');
+    if (cleanedUpJobs.length > 0) {
+      const cleanedUpJobIds = cleanedUpJobs.map(job => job._id);
+      await PageBulkExportJob.deleteMany({ _id: { $in: cleanedUpJobIds } });
+    }
+  }
+
+}
+
+// eslint-disable-next-line import/no-mutable-exports
+export let pageBulkExportJobCleanUpCronService: PageBulkExportJobCleanUpCronService | undefined; // singleton instance
+export default function instanciate(crowi: Crowi): void {
+  pageBulkExportJobCleanUpCronService = new PageBulkExportJobCleanUpCronService(crowi);
+}

+ 15 - 0
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/errors.ts

@@ -0,0 +1,15 @@
+export class BulkExportJobExpiredError extends Error {
+
+  constructor() {
+    super('Bulk export job has expired');
+  }
+
+}
+
+export class BulkExportJobRestartedError extends Error {
+
+  constructor() {
+    super('Bulk export job has restarted');
+  }
+
+}

+ 285 - 0
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts

@@ -0,0 +1,285 @@
+import fs from 'fs';
+import path from 'path';
+import type { Readable } from 'stream';
+
+import type { IUser } from '@growi/core';
+import { isPopulated, getIdForRef } from '@growi/core';
+import mongoose from 'mongoose';
+
+
+import type { SupportedActionType } from '~/interfaces/activity';
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import type Crowi from '~/server/crowi';
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import type { ActivityDocument } from '~/server/models/activity';
+import { configManager } from '~/server/service/config-manager';
+import CronService from '~/server/service/cron';
+import { preNotifyService } from '~/server/service/pre-notify';
+import loggerFactory from '~/utils/logger';
+
+import { PageBulkExportFormat, PageBulkExportJobInProgressStatus, PageBulkExportJobStatus } from '../../../interfaces/page-bulk-export';
+import type { PageBulkExportJobDocument } from '../../models/page-bulk-export-job';
+import PageBulkExportJob from '../../models/page-bulk-export-job';
+import PageBulkExportPageSnapshot from '../../models/page-bulk-export-page-snapshot';
+
+
+import { BulkExportJobExpiredError, BulkExportJobRestartedError } from './errors';
+import { requestPdfConverter } from './request-pdf-converter';
+import { compressAndUpload } from './steps/compress-and-upload';
+import { createPageSnapshotsAsync } from './steps/create-page-snapshots-async';
+import { exportPagesToFsAsync } from './steps/export-pages-to-fs-async';
+
+
+const logger = loggerFactory('growi:service:page-bulk-export-job-cron');
+
+export interface IPageBulkExportJobCronService {
+  crowi: Crowi;
+  pageBatchSize: number;
+  maxPartSize: number;
+  compressExtension: string;
+  setStreamInExecution(jobId: ObjectIdLike, stream: Readable): void;
+  removeStreamInExecution(jobId: ObjectIdLike): void;
+  handleError(err: Error | null, pageBulkExportJob: PageBulkExportJobDocument): void;
+  notifyExportResultAndCleanUp(action: SupportedActionType, pageBulkExportJob: PageBulkExportJobDocument): Promise<void>;
+  getTmpOutputDir(pageBulkExportJob: PageBulkExportJobDocument, isHtmlPath?: boolean): string;
+}
+
+/**
+ * Manages cronjob which proceeds PageBulkExportJobs in progress.
+ * If PageBulkExportJob finishes the current step, the next step will be started on the next cron execution.
+ */
+class PageBulkExportJobCronService extends CronService implements IPageBulkExportJobCronService {
+
+  crowi: Crowi;
+
+  activityEvent: any;
+
+  // multipart upload max part size
+  maxPartSize = 5 * 1024 * 1024; // 5MB
+
+  pageBatchSize = 100;
+
+  compressExtension = 'tar.gz';
+
+  // temporal path of local fs to output page files before upload
+  // TODO: If necessary, change to a proper path in https://redmine.weseek.co.jp/issues/149512
+  tmpOutputRootDir = '/tmp/page-bulk-export';
+
+  // Keep track of the stream executed for PageBulkExportJob to destroy it on job failure.
+  // The key is the id of a PageBulkExportJob.
+  private streamInExecutionMemo: {
+    [key: string]: Readable;
+  } = {};
+
+  private parallelExecLimit: number;
+
+  constructor(crowi: Crowi) {
+    super();
+    this.crowi = crowi;
+    this.activityEvent = crowi.event('activity');
+    this.parallelExecLimit = configManager.getConfig('app:pageBulkExportParallelExecLimit');
+  }
+
+  override getCronSchedule(): string {
+    return configManager.getConfig('app:pageBulkExportJobCronSchedule');
+  }
+
+  override async executeJob(): Promise<void> {
+    const pageBulkExportJobsInProgress = await PageBulkExportJob.find({
+      $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
+    }).sort({ createdAt: 1 }).limit(this.parallelExecLimit);
+
+    pageBulkExportJobsInProgress.forEach((pageBulkExportJob) => {
+      this.proceedBulkExportJob(pageBulkExportJob);
+    });
+
+    if (pageBulkExportJobsInProgress.length === 0) {
+      this.stopCron();
+    }
+  }
+
+  /**
+   * Get the output directory on the fs to temporarily store page files before compressing and uploading
+   * @param pageBulkExportJob page bulk export job in execution
+   * @param isHtmlPath whether the tmp output path is for html files
+   */
+  getTmpOutputDir(pageBulkExportJob: PageBulkExportJobDocument, isHtmlPath = false): string {
+    if (isHtmlPath) {
+      return path.join(this.tmpOutputRootDir, 'html', pageBulkExportJob._id.toString());
+    }
+    return path.join(this.tmpOutputRootDir, pageBulkExportJob._id.toString());
+  }
+
+  /**
+   * Get the stream in execution for a job.
+   * A getter method that includes "undefined" in the return type
+   */
+  getStreamInExecution(jobId: ObjectIdLike): Readable | undefined {
+    return this.streamInExecutionMemo[jobId.toString()];
+  }
+
+  /**
+   * Set the stream in execution for a job
+   */
+  setStreamInExecution(jobId: ObjectIdLike, stream: Readable) {
+    this.streamInExecutionMemo[jobId.toString()] = stream;
+  }
+
+  /**
+   * Remove the stream in execution for a job
+   */
+  removeStreamInExecution(jobId: ObjectIdLike) {
+    delete this.streamInExecutionMemo[jobId.toString()];
+  }
+
+  /**
+   * Proceed the page bulk export job if the next step is executable
+   * @param pageBulkExportJob PageBulkExportJob in progress
+   */
+  async proceedBulkExportJob(pageBulkExportJob: PageBulkExportJobDocument) {
+    try {
+      if (pageBulkExportJob.restartFlag) {
+        await this.cleanUpExportJobResources(pageBulkExportJob, true);
+        pageBulkExportJob.restartFlag = false;
+        pageBulkExportJob.status = PageBulkExportJobStatus.initializing;
+        pageBulkExportJob.statusOnPreviousCronExec = undefined;
+        await pageBulkExportJob.save();
+      }
+
+      if (pageBulkExportJob.status === PageBulkExportJobStatus.exporting && pageBulkExportJob.format === PageBulkExportFormat.pdf) {
+        await requestPdfConverter(pageBulkExportJob);
+      }
+
+      // return if job is still the same status as the previous cron exec
+      if (pageBulkExportJob.status === pageBulkExportJob.statusOnPreviousCronExec) {
+        return;
+      }
+
+      const User = mongoose.model<IUser>('User');
+      const user = await User.findById(getIdForRef(pageBulkExportJob.user));
+
+      // update statusOnPreviousCronExec before starting processes that updates status
+      pageBulkExportJob.statusOnPreviousCronExec = pageBulkExportJob.status;
+      await pageBulkExportJob.save();
+
+      if (pageBulkExportJob.status === PageBulkExportJobStatus.initializing) {
+        await createPageSnapshotsAsync.bind(this)(user, pageBulkExportJob);
+      }
+      else if (pageBulkExportJob.status === PageBulkExportJobStatus.exporting) {
+        await exportPagesToFsAsync.bind(this)(pageBulkExportJob);
+      }
+      else if (pageBulkExportJob.status === PageBulkExportJobStatus.uploading) {
+        compressAndUpload.bind(this)(user, pageBulkExportJob);
+      }
+    }
+    catch (err) {
+      logger.error(err);
+      await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED, pageBulkExportJob);
+    }
+  }
+
+  /**
+   * Handle errors that occurred during page bulk export
+   * @param err error
+   * @param pageBulkExportJob PageBulkExportJob executed in the pipeline
+   */
+  async handleError(err: Error | null, pageBulkExportJob: PageBulkExportJobDocument) {
+    if (err == null) return;
+
+    if (err instanceof BulkExportJobExpiredError) {
+      logger.error(err);
+      await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED, pageBulkExportJob);
+    }
+    else if (err instanceof BulkExportJobRestartedError) {
+      logger.info(err.message);
+      await this.cleanUpExportJobResources(pageBulkExportJob);
+    }
+    else {
+      logger.error(err);
+      await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED, pageBulkExportJob);
+    }
+  }
+
+  /**
+   * Notify the user of the export result, and cleanup the resources used in the export process
+   * @param action whether the export was successful
+   * @param pageBulkExportJob the page bulk export job
+   */
+  async notifyExportResultAndCleanUp(
+      action: SupportedActionType,
+      pageBulkExportJob: PageBulkExportJobDocument,
+  ): Promise<void> {
+    pageBulkExportJob.status = action === SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED
+      ? PageBulkExportJobStatus.completed : PageBulkExportJobStatus.failed;
+
+    try {
+      await pageBulkExportJob.save();
+      await this.notifyExportResult(pageBulkExportJob, action);
+    }
+    catch (err) {
+      logger.error(err);
+    }
+    // execute independently of notif process resolve/reject
+    await this.cleanUpExportJobResources(pageBulkExportJob);
+  }
+
+  /**
+   * Do the following in parallel:
+   * - delete page snapshots
+   * - remove the temporal output directory
+   */
+  async cleanUpExportJobResources(pageBulkExportJob: PageBulkExportJobDocument, restarted = false) {
+    const streamInExecution = this.getStreamInExecution(pageBulkExportJob._id);
+    if (streamInExecution != null) {
+      if (restarted) {
+        streamInExecution.destroy(new BulkExportJobRestartedError());
+      }
+      else {
+        streamInExecution.destroy(new BulkExportJobExpiredError());
+      }
+      this.removeStreamInExecution(pageBulkExportJob._id);
+    }
+
+    const promises = [
+      PageBulkExportPageSnapshot.deleteMany({ pageBulkExportJob }),
+      // delete /tmp/page-bulk-export/{jobId} dir
+      fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob), { recursive: true, force: true }),
+    ];
+
+    if (pageBulkExportJob.format === PageBulkExportFormat.pdf) {
+      promises.push(
+        // delete /tmp/page-bulk-export/html/{jobId} dir
+        fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob, true), { recursive: true, force: true }),
+      );
+    }
+
+    const results = await Promise.allSettled(promises);
+    results.forEach((result) => {
+      if (result.status === 'rejected') logger.error(result.reason);
+    });
+  }
+
+  private async notifyExportResult(
+      pageBulkExportJob: PageBulkExportJobDocument, action: SupportedActionType,
+  ) {
+    const activity = await this.crowi.activityService.createActivity({
+      action,
+      targetModel: SupportedTargetModel.MODEL_PAGE_BULK_EXPORT_JOB,
+      target: pageBulkExportJob,
+      user: pageBulkExportJob.user,
+      snapshot: {
+        username: isPopulated(pageBulkExportJob.user) ? pageBulkExportJob.user.username : '',
+      },
+    });
+    const getAdditionalTargetUsers = async(activity: ActivityDocument) => [activity.user];
+    const preNotify = preNotifyService.generatePreNotify(activity, getAdditionalTargetUsers);
+    this.activityEvent.emit('updated', activity, pageBulkExportJob, preNotify);
+  }
+
+}
+
+// eslint-disable-next-line import/no-mutable-exports
+export let pageBulkExportJobCronService: PageBulkExportJobCronService | undefined; // singleton instance
+export default function instanciate(crowi: Crowi): void {
+  pageBulkExportJobCronService = new PageBulkExportJobCronService(crowi);
+}

+ 62 - 0
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts

@@ -0,0 +1,62 @@
+import { PdfCtrlSyncJobStatus202Status, PdfCtrlSyncJobStatusBodyStatus, pdfCtrlSyncJobStatus } from '@growi/pdf-converter-client';
+
+import { configManager } from '~/server/service/config-manager';
+
+import { PageBulkExportJobStatus } from '../../../interfaces/page-bulk-export';
+import type { PageBulkExportJobDocument } from '../../models/page-bulk-export-job';
+import PageBulkExportPageSnapshot from '../../models/page-bulk-export-page-snapshot';
+
+import { BulkExportJobExpiredError } from './errors';
+
+/**
+ * Request PDF converter and start pdf convert for the pageBulkExportJob,
+ * or sync pdf convert status if already started.
+ */
+export async function requestPdfConverter(pageBulkExportJob: PageBulkExportJobDocument): Promise<void> {
+  const jobCreatedAt = pageBulkExportJob.createdAt;
+  if (jobCreatedAt == null) {
+    throw new Error('createdAt is not set');
+  }
+
+  const exportJobExpirationSeconds = configManager.getConfig('app:bulkExportJobExpirationSeconds');
+  const bulkExportJobExpirationDate = new Date(jobCreatedAt.getTime() + exportJobExpirationSeconds * 1000);
+  let pdfConvertStatus: PdfCtrlSyncJobStatusBodyStatus = PdfCtrlSyncJobStatusBodyStatus.HTML_EXPORT_IN_PROGRESS;
+
+  const lastExportPagePath = (await PageBulkExportPageSnapshot.findOne({ pageBulkExportJob }).sort({ path: -1 }))?.path;
+  if (lastExportPagePath == null) {
+    throw new Error('lastExportPagePath is missing');
+  }
+
+  if (new Date() > bulkExportJobExpirationDate) {
+    throw new BulkExportJobExpiredError();
+  }
+
+  try {
+    if (pageBulkExportJob.lastExportedPagePath === lastExportPagePath) {
+      pdfConvertStatus = PdfCtrlSyncJobStatusBodyStatus.HTML_EXPORT_DONE;
+    }
+
+    if (pageBulkExportJob.status === PageBulkExportJobStatus.failed) {
+      pdfConvertStatus = PdfCtrlSyncJobStatusBodyStatus.FAILED;
+    }
+
+    const res = await pdfCtrlSyncJobStatus({
+      jobId: pageBulkExportJob._id.toString(), expirationDate: bulkExportJobExpirationDate.toISOString(), status: pdfConvertStatus,
+    }, { baseURL: configManager.getConfig('app:pageBulkExportPdfConverterUri') });
+
+    if (res.data.status === PdfCtrlSyncJobStatus202Status.PDF_EXPORT_DONE) {
+      pageBulkExportJob.status = PageBulkExportJobStatus.uploading;
+      await pageBulkExportJob.save();
+    }
+    else if (res.data.status === PdfCtrlSyncJobStatus202Status.FAILED) {
+      throw new Error('PDF export failed');
+    }
+  }
+  catch (err) {
+    // Only set as failure when host is ready but failed.
+    // If host is not ready, the request should be retried on the next cron execution.
+    if (!['ENOTFOUND', 'ECONNREFUSED'].includes(err.code)) {
+      throw err;
+    }
+  }
+}

+ 70 - 0
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts

@@ -0,0 +1,70 @@
+import type { Archiver } from 'archiver';
+import archiver from 'archiver';
+
+import { PageBulkExportJobStatus } from '~/features/page-bulk-export/interfaces/page-bulk-export';
+import { SupportedAction } from '~/interfaces/activity';
+import { AttachmentType } from '~/server/interfaces/attachment';
+import type { IAttachmentDocument } from '~/server/models/attachment';
+import { Attachment } from '~/server/models/attachment';
+import type { FileUploader } from '~/server/service/file-uploader';
+import loggerFactory from '~/utils/logger';
+
+import type { IPageBulkExportJobCronService } from '..';
+import type { PageBulkExportJobDocument } from '../../../models/page-bulk-export-job';
+
+const logger = loggerFactory('growi:service:page-bulk-export-job-cron:compress-and-upload-async');
+
+function setUpPageArchiver(): Archiver {
+  const pageArchiver = archiver('tar', {
+    gzip: true,
+  });
+
+  // good practice to catch warnings (ie stat failures and other non-blocking errors)
+  pageArchiver.on('warning', (err) => {
+    if (err.code === 'ENOENT') logger.error(err);
+    else throw err;
+  });
+
+  return pageArchiver;
+}
+
+async function postProcess(
+    this: IPageBulkExportJobCronService, pageBulkExportJob: PageBulkExportJobDocument, attachment: IAttachmentDocument, fileSize: number,
+): Promise<void> {
+  attachment.fileSize = fileSize;
+  await attachment.save();
+
+  pageBulkExportJob.completedAt = new Date();
+  pageBulkExportJob.attachment = attachment._id;
+  pageBulkExportJob.status = PageBulkExportJobStatus.completed;
+  await pageBulkExportJob.save();
+
+  this.removeStreamInExecution(pageBulkExportJob._id);
+  await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED, pageBulkExportJob);
+}
+
+/**
+ * Execute a pipeline that reads the page files from the temporal fs directory, compresses them, and uploads to the cloud storage
+ */
+export async function compressAndUpload(this: IPageBulkExportJobCronService, user, pageBulkExportJob: PageBulkExportJobDocument): Promise<void> {
+  const pageArchiver = setUpPageArchiver();
+
+  if (pageBulkExportJob.revisionListHash == null) throw new Error('revisionListHash is not set');
+  const originalName = `${pageBulkExportJob.revisionListHash}.${this.compressExtension}`;
+  const attachment = Attachment.createWithoutSave(null, user, originalName, this.compressExtension, 0, AttachmentType.PAGE_BULK_EXPORT);
+
+  const fileUploadService: FileUploader = this.crowi.fileUploadService;
+
+  pageArchiver.directory(this.getTmpOutputDir(pageBulkExportJob), false);
+  pageArchiver.finalize();
+  this.setStreamInExecution(pageBulkExportJob._id, pageArchiver);
+
+  try {
+    await fileUploadService.uploadAttachment(pageArchiver, attachment);
+  }
+  catch (e) {
+    logger.error(e);
+    this.handleError(e, pageBulkExportJob);
+  }
+  await postProcess.bind(this)(pageBulkExportJob, attachment, pageArchiver.pointer());
+}

+ 103 - 0
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/create-page-snapshots-async.ts

@@ -0,0 +1,103 @@
+import { createHash } from 'crypto';
+import { Writable, pipeline } from 'stream';
+
+import { getIdForRef, getIdStringForRef } from '@growi/core';
+import type { IPage } from '@growi/core';
+import mongoose from 'mongoose';
+
+import { PageBulkExportJobStatus } from '~/features/page-bulk-export/interfaces/page-bulk-export';
+import { SupportedAction } from '~/interfaces/activity';
+import type { PageDocument, PageModel } from '~/server/models/page';
+
+import type { IPageBulkExportJobCronService } from '..';
+import type { PageBulkExportJobDocument } from '../../../models/page-bulk-export-job';
+import PageBulkExportJob from '../../../models/page-bulk-export-job';
+import PageBulkExportPageSnapshot from '../../../models/page-bulk-export-page-snapshot';
+
+async function reuseDuplicateExportIfExists(this: IPageBulkExportJobCronService, pageBulkExportJob: PageBulkExportJobDocument) {
+  const duplicateExportJob = await PageBulkExportJob.findOne({
+    user: pageBulkExportJob.user,
+    page: pageBulkExportJob.page,
+    format: pageBulkExportJob.format,
+    status: PageBulkExportJobStatus.completed,
+    revisionListHash: pageBulkExportJob.revisionListHash,
+  });
+  if (duplicateExportJob != null) {
+    // if an upload with the exact same contents exists, re-use the same attachment of that upload
+    pageBulkExportJob.attachment = duplicateExportJob.attachment;
+    pageBulkExportJob.status = PageBulkExportJobStatus.completed;
+    await pageBulkExportJob.save();
+
+    await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED, pageBulkExportJob);
+  }
+}
+
+/**
+ * Start a pipeline that creates a snapshot for each page that is to be exported in the pageBulkExportJob.
+ * 'revisionListHash' is calulated and saved to the pageBulkExportJob at the end of the pipeline.
+ */
+export async function createPageSnapshotsAsync(this: IPageBulkExportJobCronService, user, pageBulkExportJob: PageBulkExportJobDocument): Promise<void> {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+
+  // if the process of creating snapshots was interrupted, delete the snapshots and create from the start
+  await PageBulkExportPageSnapshot.deleteMany({ pageBulkExportJob });
+
+  const basePage = await Page.findById(getIdForRef(pageBulkExportJob.page));
+  if (basePage == null) {
+    throw new Error('Base page not found');
+  }
+
+  const revisionListHash = createHash('sha256');
+
+  // create a Readable for pages to be exported
+  const { PageQueryBuilder } = Page;
+  const builder = await new PageQueryBuilder(Page.find())
+    .addConditionToListWithDescendants(basePage.path)
+    .addViewerCondition(user);
+  const pagesReadable = builder
+    .query
+    .lean()
+    .cursor({ batchSize: this.pageBatchSize });
+
+  // create a Writable that creates a snapshot for each page
+  const pageSnapshotsWritable = new Writable({
+    objectMode: true,
+    write: async(page: PageDocument, encoding, callback) => {
+      try {
+        if (page.revision != null) {
+          revisionListHash.update(getIdStringForRef(page.revision));
+        }
+        await PageBulkExportPageSnapshot.create({
+          pageBulkExportJob,
+          path: page.path,
+          revision: page.revision,
+        });
+      }
+      catch (err) {
+        callback(err);
+        return;
+      }
+      callback();
+    },
+    final: async(callback) => {
+      try {
+        pageBulkExportJob.revisionListHash = revisionListHash.digest('hex');
+        pageBulkExportJob.status = PageBulkExportJobStatus.exporting;
+        await pageBulkExportJob.save();
+
+        await reuseDuplicateExportIfExists.bind(this)(pageBulkExportJob);
+      }
+      catch (err) {
+        callback(err);
+        return;
+      }
+      callback();
+    },
+  });
+
+  this.setStreamInExecution(pageBulkExportJob._id, pagesReadable);
+
+  pipeline(pagesReadable, pageSnapshotsWritable, (err) => {
+    this.handleError(err, pageBulkExportJob);
+  });
+}

+ 112 - 0
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/export-pages-to-fs-async.ts

@@ -0,0 +1,112 @@
+import fs from 'fs';
+import path from 'path';
+import { Writable, pipeline } from 'stream';
+
+import { dynamicImport } from '@cspell/dynamic-import';
+import { isPopulated } from '@growi/core';
+import { getParentPath, normalizePath } from '@growi/core/dist/utils/path-utils';
+import type { Root } from 'mdast';
+import type * as RemarkHtml from 'remark-html';
+import type * as RemarkParse from 'remark-parse';
+import type * as Unified from 'unified';
+
+import { PageBulkExportFormat, PageBulkExportJobStatus } from '~/features/page-bulk-export/interfaces/page-bulk-export';
+
+import type { IPageBulkExportJobCronService } from '..';
+import type { PageBulkExportJobDocument } from '../../../models/page-bulk-export-job';
+import type { PageBulkExportPageSnapshotDocument } from '../../../models/page-bulk-export-page-snapshot';
+import PageBulkExportPageSnapshot from '../../../models/page-bulk-export-page-snapshot';
+
+async function convertMdToHtml(md: string, htmlConverter: Unified.Processor<Root, undefined, undefined, Root, string>): Promise<string> {
+  const htmlString = (await htmlConverter
+    .process(md))
+    .toString();
+
+  return htmlString;
+}
+
+/**
+ * Get a Writable that writes the page body temporarily to fs
+ */
+async function getPageWritable(this: IPageBulkExportJobCronService, pageBulkExportJob: PageBulkExportJobDocument): Promise<Writable> {
+  const unified = (await dynamicImport<typeof Unified>('unified', __dirname)).unified;
+  const remarkParse = (await dynamicImport<typeof RemarkParse>('remark-parse', __dirname)).default;
+  const remarkHtml = (await dynamicImport<typeof RemarkHtml>('remark-html', __dirname)).default;
+
+  const isHtmlPath = pageBulkExportJob.format === PageBulkExportFormat.pdf;
+  const format = pageBulkExportJob.format === PageBulkExportFormat.pdf ? 'html' : pageBulkExportJob.format;
+  const outputDir = this.getTmpOutputDir(pageBulkExportJob, isHtmlPath);
+  // define before the stream starts to avoid creating multiple instances
+  const htmlConverter = unified()
+    .use(remarkParse)
+    .use(remarkHtml);
+  return new Writable({
+    objectMode: true,
+    write: async(page: PageBulkExportPageSnapshotDocument, encoding, callback) => {
+      try {
+        const revision = page.revision;
+
+        if (revision != null && isPopulated(revision)) {
+          const markdownBody = revision.body;
+          const pathNormalized = `${normalizePath(page.path)}.${format}`;
+          const fileOutputPath = path.join(outputDir, pathNormalized);
+          const fileOutputParentPath = getParentPath(fileOutputPath);
+
+          await fs.promises.mkdir(fileOutputParentPath, { recursive: true });
+          if (pageBulkExportJob.format === PageBulkExportFormat.md) {
+            await fs.promises.writeFile(fileOutputPath, markdownBody);
+          }
+          else {
+            const htmlString = await convertMdToHtml(markdownBody, htmlConverter);
+            await fs.promises.writeFile(fileOutputPath, htmlString);
+          }
+          pageBulkExportJob.lastExportedPagePath = page.path;
+          await pageBulkExportJob.save();
+        }
+      }
+      catch (err) {
+        callback(err);
+        return;
+      }
+      callback();
+    },
+    final: async(callback) => {
+      try {
+        // If the format is md, the export process ends here.
+        // If the format is pdf, pdf conversion in pdf-converter has to finish.
+        if (pageBulkExportJob.format === PageBulkExportFormat.md) {
+          pageBulkExportJob.status = PageBulkExportJobStatus.uploading;
+          await pageBulkExportJob.save();
+        }
+      }
+      catch (err) {
+        callback(err);
+        return;
+      }
+      callback();
+    },
+  });
+}
+
+/**
+ * Export pages to the file system before compressing and uploading to the cloud storage.
+ * The export will resume from the last exported page if the process was interrupted.
+ */
+export async function exportPagesToFsAsync(this: IPageBulkExportJobCronService, pageBulkExportJob: PageBulkExportJobDocument): Promise<void> {
+  const findQuery = pageBulkExportJob.lastExportedPagePath != null ? {
+    pageBulkExportJob,
+    path: { $gt: pageBulkExportJob.lastExportedPagePath },
+  } : { pageBulkExportJob };
+  const pageSnapshotsReadable = PageBulkExportPageSnapshot
+    .find(findQuery)
+    .populate('revision').sort({ path: 1 }).lean()
+    .cursor({ batchSize: this.pageBatchSize });
+
+  const pagesWritable = await getPageWritable.bind(this)(pageBulkExportJob);
+
+  this.setStreamInExecution(pageBulkExportJob._id, pageSnapshotsReadable);
+
+  pipeline(pageSnapshotsReadable, pagesWritable, (err) => {
+    this.handleError(err, pageBulkExportJob);
+  });
+}

+ 78 - 0
apps/app/src/features/page-bulk-export/server/service/page-bulk-export.ts

@@ -0,0 +1,78 @@
+import {
+  type IPage, SubscriptionStatusType,
+} from '@growi/core';
+import type { HydratedDocument } from 'mongoose';
+import mongoose from 'mongoose';
+
+
+import { SupportedTargetModel } from '~/interfaces/activity';
+import type { PageModel } from '~/server/models/page';
+import Subscription from '~/server/models/subscription';
+import loggerFactory from '~/utils/logger';
+
+import type { PageBulkExportFormat } from '../../interfaces/page-bulk-export';
+import { PageBulkExportJobInProgressStatus, PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
+import type { PageBulkExportJobDocument } from '../models/page-bulk-export-job';
+import PageBulkExportJob from '../models/page-bulk-export-job';
+
+const logger = loggerFactory('growi:services:PageBulkExportService');
+
+export class DuplicateBulkExportJobError extends Error {
+
+  duplicateJob: HydratedDocument<PageBulkExportJobDocument>;
+
+  constructor(duplicateJob: HydratedDocument<PageBulkExportJobDocument>) {
+    super('Duplicate bulk export job is in progress');
+    this.duplicateJob = duplicateJob;
+  }
+
+}
+
+export interface IPageBulkExportService {
+  createOrResetBulkExportJob: (basePagePath: string, currentUser, restartJob?: boolean) => Promise<void>;
+}
+
+class PageBulkExportService implements IPageBulkExportService {
+
+  /**
+   * Create a new page bulk export job or reset the existing one
+   */
+  async createOrResetBulkExportJob(basePagePath: string, format: PageBulkExportFormat, currentUser, restartJob = false): Promise<void> {
+    const Page = mongoose.model<IPage, PageModel>('Page');
+    const basePage = await Page.findByPathAndViewer(basePagePath, currentUser, null, true);
+
+    if (basePage == null) {
+      throw new Error('Base page not found or not accessible');
+    }
+
+    const duplicatePageBulkExportJobInProgress: HydratedDocument<PageBulkExportJobDocument> | null = await PageBulkExportJob.findOne({
+      user: { $eq: currentUser },
+      page: basePage,
+      format: { $eq: format },
+      $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
+    });
+    if (duplicatePageBulkExportJobInProgress != null) {
+      if (restartJob) {
+        this.resetBulkExportJob(duplicatePageBulkExportJobInProgress);
+        return;
+      }
+      throw new DuplicateBulkExportJobError(duplicatePageBulkExportJobInProgress);
+    }
+    const pageBulkExportJob: HydratedDocument<PageBulkExportJobDocument> = await PageBulkExportJob.create({
+      user: currentUser, page: basePage, format, status: PageBulkExportJobStatus.initializing,
+    });
+
+    await Subscription.upsertSubscription(currentUser, SupportedTargetModel.MODEL_PAGE_BULK_EXPORT_JOB, pageBulkExportJob, SubscriptionStatusType.SUBSCRIBE);
+  }
+
+  /**
+   * Reset page bulk export job in progress
+   */
+  async resetBulkExportJob(pageBulkExportJob: HydratedDocument<PageBulkExportJobDocument>): Promise<void> {
+    pageBulkExportJob.restartFlag = true;
+    await pageBulkExportJob.save();
+  }
+
+}
+
+export const pageBulkExportService: PageBulkExportService = new PageBulkExportService(); // singleton instance

+ 28 - 28
apps/app/test/integration/service/questionnaire-cron.test.ts → apps/app/src/features/questionnaire/server/service/questionnaire-cron.integ.ts

@@ -3,31 +3,33 @@ import { GrowiDeploymentType, GrowiServiceType, GrowiWikiType } from '@growi/cor
 import axios from 'axios';
 import axios from 'axios';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
+import { configManager } from '~/server/service/config-manager';
+
+import { AttachmentMethodType } from '../../../../interfaces/attachment';
 import type {
 import type {
   IProactiveQuestionnaireAnswer, IProactiveQuestionnaireAnswerLegacy,
   IProactiveQuestionnaireAnswer, IProactiveQuestionnaireAnswerLegacy,
-} from '../../../src/features/questionnaire/interfaces/proactive-questionnaire-answer';
-import type { IQuestionnaireAnswer, IQuestionnaireAnswerLegacy } from '../../../src/features/questionnaire/interfaces/questionnaire-answer';
-import { StatusType } from '../../../src/features/questionnaire/interfaces/questionnaire-answer-status';
-import ProactiveQuestionnaireAnswer from '../../../src/features/questionnaire/server/models/proactive-questionnaire-answer';
-import QuestionnaireAnswer from '../../../src/features/questionnaire/server/models/questionnaire-answer';
-import QuestionnaireAnswerStatus from '../../../src/features/questionnaire/server/models/questionnaire-answer-status';
-import QuestionnaireOrder from '../../../src/features/questionnaire/server/models/questionnaire-order';
-import { AttachmentMethodType } from '../../../src/interfaces/attachment';
-import { getInstance } from '../setup-crowi';
+} from '../../interfaces/proactive-questionnaire-answer';
+import type { IQuestionnaireAnswer, IQuestionnaireAnswerLegacy } from '../../interfaces/questionnaire-answer';
+import { StatusType } from '../../interfaces/questionnaire-answer-status';
+import ProactiveQuestionnaireAnswer from '../models/proactive-questionnaire-answer';
+import QuestionnaireAnswer from '../models/questionnaire-answer';
+import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
+import QuestionnaireOrder from '../models/questionnaire-order';
 
 
-const spyAxiosGet = jest.spyOn<typeof axios, 'get'>(
-  axios,
-  'get',
-);
+import questionnaireCronService from './questionnaire-cron';
 
 
-const spyAxiosPost = jest.spyOn<typeof axios, 'post'>(
-  axios,
-  'post',
-);
+// TODO: use actual user model after ~/server/models/user.js becomes importable in vitest
+// ref: https://github.com/vitest-dev/vitest/issues/846
+const userSchema = new mongoose.Schema({
+  name: { type: String },
+  username: { type: String, required: true, unique: true },
+  email: { type: String, unique: true, sparse: true },
+}, {
+  timestamps: true,
+});
+const User = mongoose.model('User', userSchema);
 
 
 describe('QuestionnaireCronService', () => {
 describe('QuestionnaireCronService', () => {
-  let crowi;
-
   const mockResponse = {
   const mockResponse = {
     data: {
     data: {
       questionnaireOrders: [
       questionnaireOrders: [
@@ -141,14 +143,12 @@ describe('QuestionnaireCronService', () => {
   };
   };
 
 
   beforeAll(async() => {
   beforeAll(async() => {
-    crowi = await getInstance();
-    const User = crowi.model('User');
-    User.deleteMany({}); // clear users
+    await configManager.loadConfigs();
+    await configManager.updateConfig('app:questionnaireCronMaxHoursUntilRequest', 0);
     await User.create({
     await User.create({
       name: 'Example for Questionnaire Service Test',
       name: 'Example for Questionnaire Service Test',
       username: 'questionnaire cron test user',
       username: 'questionnaire cron test user',
       email: 'questionnaireCronTestUser@example.com',
       email: 'questionnaireCronTestUser@example.com',
-      password: 'usertestpass',
       createdAt: '2020-01-01',
       createdAt: '2020-01-01',
     });
     });
   });
   });
@@ -419,19 +419,19 @@ describe('QuestionnaireCronService', () => {
       validProactiveQuestionnaireAnswerLegacy,
       validProactiveQuestionnaireAnswerLegacy,
     ]);
     ]);
 
 
-    crowi.setupCron();
+    questionnaireCronService.startCron();
 
 
-    spyAxiosGet.mockResolvedValue(mockResponse);
-    spyAxiosPost.mockResolvedValue({ data: { result: 'success' } });
+    vi.spyOn(axios, 'get').mockResolvedValue(mockResponse);
+    vi.spyOn(axios, 'post').mockResolvedValue({ data: { result: 'success' } });
   });
   });
 
 
   afterAll(() => {
   afterAll(() => {
-    crowi.questionnaireCronService.stopCron(); // jest will not finish until cronjob stops
+    questionnaireCronService.stopCron(); // vitest will not finish until cronjob stops
   });
   });
 
 
   test('Job execution should save(update) quesionnaire orders, delete outdated ones, update skipped answer statuses, and delete resent answers', async() => {
   test('Job execution should save(update) quesionnaire orders, delete outdated ones, update skipped answer statuses, and delete resent answers', async() => {
     // testing the cronjob from schedule has untrivial overhead, so test job execution in place
     // testing the cronjob from schedule has untrivial overhead, so test job execution in place
-    await crowi.questionnaireCronService.executeJob();
+    await questionnaireCronService.executeJob();
 
 
     const savedOrders = await QuestionnaireOrder.find()
     const savedOrders = await QuestionnaireOrder.find()
       .select('-condition._id -questions._id -questions.createdAt -questions.updatedAt')
       .select('-condition._id -questions._id -questions.createdAt -questions.updatedAt')

+ 16 - 42
apps/app/src/features/questionnaire/server/service/questionnaire-cron.ts

@@ -1,8 +1,7 @@
 import axiosRetry from 'axios-retry';
 import axiosRetry from 'axios-retry';
 
 
-import type Crowi from '~/server/crowi';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
-import loggerFactory from '~/utils/logger';
+import CronService from '~/server/service/cron';
 import { getRandomIntInRange } from '~/utils/rand';
 import { getRandomIntInRange } from '~/utils/rand';
 
 
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
@@ -13,48 +12,29 @@ import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
 import QuestionnaireOrder from '../models/questionnaire-order';
 import QuestionnaireOrder from '../models/questionnaire-order';
 import { convertToLegacyFormat } from '../util/convert-to-legacy-format';
 import { convertToLegacyFormat } from '../util/convert-to-legacy-format';
 
 
-const logger = loggerFactory('growi:service:questionnaire-cron');
-
 const axios = require('axios').default;
 const axios = require('axios').default;
-const nodeCron = require('node-cron');
 
 
 axiosRetry(axios, { retries: 3 });
 axiosRetry(axios, { retries: 3 });
 
 
 /**
 /**
- * manage cronjob which
+ * Manages cronjob which
  *  1. fetches QuestionnaireOrders from questionnaire server
  *  1. fetches QuestionnaireOrders from questionnaire server
  *  2. updates QuestionnaireOrder collection to contain only the ones that exist in the fetched list and is not finished (doesn't have to be started)
  *  2. updates QuestionnaireOrder collection to contain only the ones that exist in the fetched list and is not finished (doesn't have to be started)
  *  3. changes QuestionnaireAnswerStatuses which are 'skipped' to 'not_answered'
  *  3. changes QuestionnaireAnswerStatuses which are 'skipped' to 'not_answered'
  *  4. resend QuestionnaireAnswers & ProactiveQuestionnaireAnswers which failed to reach questionnaire server
  *  4. resend QuestionnaireAnswers & ProactiveQuestionnaireAnswers which failed to reach questionnaire server
  */
  */
-class QuestionnaireCronService {
-
-  crowi: Crowi;
-
-  cronJob: any;
-
-  constructor(crowi: Crowi) {
-    this.crowi = crowi;
-  }
+class QuestionnaireCronService extends CronService {
 
 
   sleep = (msec: number): Promise<void> => new Promise(resolve => setTimeout(resolve, msec));
   sleep = (msec: number): Promise<void> => new Promise(resolve => setTimeout(resolve, msec));
 
 
-  startCron(): void {
-    const cronSchedule = this.crowi.configManager.getConfig('app:questionnaireCronSchedule');
-    const maxHoursUntilRequest = this.crowi.configManager.getConfig('app:questionnaireCronMaxHoursUntilRequest');
-
-    const maxSecondsUntilRequest = maxHoursUntilRequest * 60 * 60;
-
-    this.cronJob?.stop();
-    this.cronJob = this.generateCronJob(cronSchedule, maxSecondsUntilRequest);
-    this.cronJob.start();
+  override getCronSchedule(): string {
+    return configManager.getConfig('app:questionnaireCronSchedule');
   }
   }
 
 
-  stopCron(): void {
-    this.cronJob.stop();
-  }
+  override async executeJob(): Promise<void> {
+    // sleep for a random amount to scatter request time from GROWI apps to questionnaire server
+    await this.sleepBeforeJob();
 
 
-  async executeJob(): Promise<void> {
     const questionnaireServerOrigin = configManager.getConfig('app:questionnaireServerOrigin');
     const questionnaireServerOrigin = configManager.getConfig('app:questionnaireServerOrigin');
     const isAppSiteUrlHashed = configManager.getConfig('questionnaire:isAppSiteUrlHashed');
     const isAppSiteUrlHashed = configManager.getConfig('questionnaire:isAppSiteUrlHashed');
 
 
@@ -111,22 +91,16 @@ class QuestionnaireCronService {
     await changeSkippedAnswerStatusToNotAnswered();
     await changeSkippedAnswerStatusToNotAnswered();
   }
   }
 
 
-  private generateCronJob(cronSchedule: string, maxSecondsUntilRequest: number) {
-    return nodeCron.schedule(cronSchedule, async() => {
-      // sleep for a random amount to scatter request time from GROWI apps to questionnaire server
-      const secToSleep = getRandomIntInRange(0, maxSecondsUntilRequest);
-      await this.sleep(secToSleep * 1000);
-
-      try {
-        this.executeJob();
-      }
-      catch (e) {
-        logger.error(e);
-      }
+  private async sleepBeforeJob() {
+    const maxHoursUntilRequest = configManager.getConfig('app:questionnaireCronMaxHoursUntilRequest');
+    const maxSecondsUntilRequest = maxHoursUntilRequest * 60 * 60;
 
 
-    });
+    const secToSleep = getRandomIntInRange(0, maxSecondsUntilRequest);
+    await this.sleep(secToSleep * 1000);
   }
   }
 
 
 }
 }
 
 
-export default QuestionnaireCronService;
+const questionnaireCronService = new QuestionnaireCronService();
+
+export default questionnaireCronService;

+ 14 - 0
apps/app/src/interfaces/activity.ts

@@ -4,6 +4,7 @@ import type { Ref, HasObjectId, IUser } from '@growi/core';
 const MODEL_PAGE = 'Page';
 const MODEL_PAGE = 'Page';
 const MODEL_USER = 'User';
 const MODEL_USER = 'User';
 const MODEL_COMMENT = 'Comment';
 const MODEL_COMMENT = 'Comment';
+const MODEL_PAGE_BULK_EXPORT_JOB = 'PageBulkExportJob';
 
 
 // Action
 // Action
 const ACTION_UNSETTLED = 'UNSETTLED';
 const ACTION_UNSETTLED = 'UNSETTLED';
@@ -52,6 +53,9 @@ const ACTION_PAGE_RECURSIVELY_REVERT = 'PAGE_RECURSIVELY_REVERT';
 const ACTION_PAGE_SUBSCRIBE = 'PAGE_SUBSCRIBE';
 const ACTION_PAGE_SUBSCRIBE = 'PAGE_SUBSCRIBE';
 const ACTION_PAGE_UNSUBSCRIBE = 'PAGE_UNSUBSCRIBE';
 const ACTION_PAGE_UNSUBSCRIBE = 'PAGE_UNSUBSCRIBE';
 const ACTION_PAGE_EXPORT = 'PAGE_EXPORT';
 const ACTION_PAGE_EXPORT = 'PAGE_EXPORT';
+const ACTION_PAGE_BULK_EXPORT_COMPLETED = 'PAGE_BULK_EXPORT_COMPLETED';
+const ACTION_PAGE_BULK_EXPORT_FAILED = 'PAGE_BULK_EXPORT_FAILED';
+const ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED = 'PAGE_BULK_EXPORT_JOB_EXPIRED';
 const ACTION_TAG_UPDATE = 'TAG_UPDATE';
 const ACTION_TAG_UPDATE = 'TAG_UPDATE';
 const ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN = 'IN_APP_NOTIFICATION_ALL_STATUSES_OPEN';
 const ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN = 'IN_APP_NOTIFICATION_ALL_STATUSES_OPEN';
 const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
 const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
@@ -76,6 +80,7 @@ const ACTION_ADMIN_MAIL_SES_UPDATE = 'ADMIN_MAIL_SES_UPDATE';
 const ACTION_ADMIN_MAIL_TEST_SUBMIT = 'ADMIN_MAIL_TEST_SUBMIT';
 const ACTION_ADMIN_MAIL_TEST_SUBMIT = 'ADMIN_MAIL_TEST_SUBMIT';
 const ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE = 'ADMIN_FILE_UPLOAD_CONFIG_UPDATE';
 const ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE = 'ADMIN_FILE_UPLOAD_CONFIG_UPDATE';
 const ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE = 'ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE';
 const ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE = 'ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE';
+const ACTION_ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE = 'ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE';
 const ACTION_ADMIN_MAINTENANCEMODE_ENABLED = 'ADMIN_MAINTENANCEMODE_ENABLED';
 const ACTION_ADMIN_MAINTENANCEMODE_ENABLED = 'ADMIN_MAINTENANCEMODE_ENABLED';
 const ACTION_ADMIN_MAINTENANCEMODE_DISABLED = 'ADMIN_MAINTENANCEMODE_DISABLED';
 const ACTION_ADMIN_MAINTENANCEMODE_DISABLED = 'ADMIN_MAINTENANCEMODE_DISABLED';
 const ACTION_ADMIN_SECURITY_SETTINGS_UPDATE = 'ADMIN_SECURITY_SETTINGS_UPDATE';
 const ACTION_ADMIN_SECURITY_SETTINGS_UPDATE = 'ADMIN_SECURITY_SETTINGS_UPDATE';
@@ -167,6 +172,7 @@ const ACTION_ADMIN_SEARCH_INDICES_REBUILD = 'ADMIN_SEARCH_INDICES_REBUILD';
 export const SupportedTargetModel = {
 export const SupportedTargetModel = {
   MODEL_PAGE,
   MODEL_PAGE,
   MODEL_USER,
   MODEL_USER,
+  MODEL_PAGE_BULK_EXPORT_JOB,
 } as const;
 } as const;
 
 
 export const SupportedEventModel = {
 export const SupportedEventModel = {
@@ -256,6 +262,7 @@ export const SupportedAction = {
   ACTION_ADMIN_MAIL_TEST_SUBMIT,
   ACTION_ADMIN_MAIL_TEST_SUBMIT,
   ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
   ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
   ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE,
   ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE,
+  ACTION_ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE,
   ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
   ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
   ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
   ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
   ACTION_ADMIN_SECURITY_SETTINGS_UPDATE,
   ACTION_ADMIN_SECURITY_SETTINGS_UPDATE,
@@ -342,6 +349,9 @@ export const SupportedAction = {
   ACTION_ADMIN_SEARCH_CONNECTION,
   ACTION_ADMIN_SEARCH_CONNECTION,
   ACTION_ADMIN_SEARCH_INDICES_NORMALIZE,
   ACTION_ADMIN_SEARCH_INDICES_NORMALIZE,
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
+  ACTION_PAGE_BULK_EXPORT_COMPLETED,
+  ACTION_PAGE_BULK_EXPORT_FAILED,
+  ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED,
 } as const;
 } as const;
 
 
 // Action required for notification
 // Action required for notification
@@ -360,6 +370,9 @@ export const EssentialActionGroup = {
   ACTION_PAGE_RECURSIVELY_REVERT,
   ACTION_PAGE_RECURSIVELY_REVERT,
   ACTION_COMMENT_CREATE,
   ACTION_COMMENT_CREATE,
   ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
   ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
+  ACTION_PAGE_BULK_EXPORT_COMPLETED,
+  ACTION_PAGE_BULK_EXPORT_FAILED,
+  ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED,
 } as const;
 } as const;
 
 
 export const ActionGroupSize = {
 export const ActionGroupSize = {
@@ -445,6 +458,7 @@ export const LargeActionGroup = {
   ACTION_ADMIN_MAIL_TEST_SUBMIT,
   ACTION_ADMIN_MAIL_TEST_SUBMIT,
   ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
   ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
   ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE,
   ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE,
+  ACTION_ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE,
   ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
   ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
   ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
   ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
   ACTION_ADMIN_SECURITY_SETTINGS_UPDATE,
   ACTION_ADMIN_SECURITY_SETTINGS_UPDATE,

+ 28 - 0
apps/app/src/interfaces/file-uploader.ts

@@ -0,0 +1,28 @@
+// file upload types actually supported by the app
+export const FileUploadType = {
+  aws: 'aws',
+  gcs: 'gcs',
+  azure: 'azure',
+  gridfs: 'gridfs',
+  local: 'local',
+} as const;
+
+export type FileUploadType = typeof FileUploadType[keyof typeof FileUploadType]
+
+// file upload type strings you can specify in the env variable
+export const FileUploadTypeForEnvVar = {
+  ...FileUploadType,
+  mongo:   'mongo',
+  mongodb: 'mongodb',
+  gcp:     'gcp',
+} as const;
+
+export type FileUploadTypeForEnvVar = typeof FileUploadTypeForEnvVar[keyof typeof FileUploadTypeForEnvVar]
+
+// mapping from env variable to actual module name
+export const EnvToModuleMappings = {
+  ...FileUploadTypeForEnvVar,
+  mongo:   'gridfs',
+  mongodb: 'gridfs',
+  gcp:     'gcs',
+} as const;

+ 5 - 0
apps/app/src/interfaces/res/admin/app-settings.ts

@@ -59,4 +59,9 @@ export type IResAppSettings = {
   isAppSiteUrlHashed: boolean,
   isAppSiteUrlHashed: boolean,
 
 
   isMaintenanceMode: boolean,
   isMaintenanceMode: boolean,
+
+  isBulkExportPagesEnabled: boolean,
+  envIsBulkExportPagesEnabled: boolean,
+  bulkExportDownloadExpirationSeconds: number,
+  useOnlyEnvVarsForIsBulkExportPagesEnabled: boolean,
 }
 }

+ 12 - 0
apps/app/src/migrations/19700101000000-foremost-1000-20241123211930-remove-index-for-ns-from-configs.js

@@ -24,6 +24,18 @@ module.exports = {
     logger.info('Apply migration');
     logger.info('Apply migration');
     await mongoose.connect(getMongoUri(), mongoOptions);
     await mongoose.connect(getMongoUri(), mongoOptions);
 
 
+    // remove unnecessary data
+    // see: https://redmine.weseek.co.jp/issues/163527
+    await db.collection('configs').deleteMany({
+      ns: 'crowi',
+      key: {
+        $in: [
+          'notification:owner-page:isEnabled',
+          'notification:group-page:isEnabled',
+        ],
+      },
+    });
+
     // drop index
     // drop index
     await dropIndexIfExists(db, 'configs', 'ns_1_key_1');
     await dropIndexIfExists(db, 'configs', 'ns_1_key_1');
 
 

+ 25 - 0
apps/app/src/models/serializers/in-app-notification-snapshot/page-bulk-export-job.ts

@@ -0,0 +1,25 @@
+import { isPopulated } from '@growi/core';
+import type { IPage } from '@growi/core';
+import mongoose from 'mongoose';
+
+import type { IPageBulkExportJob } from '~/features/page-bulk-export/interfaces/page-bulk-export';
+import type { PageModel } from '~/server/models/page';
+
+export interface IPageBulkExportJobSnapshot {
+  path: string
+}
+
+export const stringifySnapshot = async(exportJob: IPageBulkExportJob): Promise<string | undefined> => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const page = isPopulated(exportJob.page) ? exportJob.page : (await Page.findById(exportJob.page));
+
+  if (page != null) {
+    return JSON.stringify({
+      path: page.path,
+    });
+  }
+};
+
+export const parseSnapshot = (snapshot: string): IPageBulkExportJobSnapshot => {
+  return JSON.parse(snapshot);
+};

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

@@ -42,10 +42,11 @@ import {
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useIsEnabledMarp, useCurrentPathname,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useIsEnabledMarp, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useGrowiCloudUri,
   useIsSlackConfigured, useRendererConfig, useGrowiCloudUri,
   useIsAllReplyShown, useShowPageSideAuthors, useIsContainerFluid, useIsNotCreatable,
   useIsAllReplyShown, useShowPageSideAuthors, useIsContainerFluid, useIsNotCreatable,
-  useIsUploadAllFileAllowed, useIsUploadEnabled,
+  useIsUploadAllFileAllowed, useIsUploadEnabled, useIsBulkExportPagesEnabled,
   useElasticsearchMaxBodyLengthToIndex,
   useElasticsearchMaxBodyLengthToIndex,
   useIsLocalAccountRegistrationEnabled,
   useIsLocalAccountRegistrationEnabled,
   useIsRomUserAllowedToComment,
   useIsRomUserAllowedToComment,
+  useIsPdfBulkExportEnabled,
   useIsAiEnabled, useLimitLearnablePageCountPerAssistant,
   useIsAiEnabled, useLimitLearnablePageCountPerAssistant,
 } from '~/stores-universal/context';
 } from '~/stores-universal/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import { useEditingMarkdown } from '~/stores/editor';
@@ -181,6 +182,8 @@ type Props = CommonProps & {
   isContainerFluid: boolean,
   isContainerFluid: boolean,
   isUploadEnabled: boolean,
   isUploadEnabled: boolean,
   isUploadAllFileAllowed: boolean,
   isUploadAllFileAllowed: boolean,
+  isBulkExportPagesEnabled: boolean,
+  isPdfBulkExportEnabled: boolean,
   isEnabledStaleNotification: boolean,
   isEnabledStaleNotification: boolean,
   isEnabledAttachTitleHeader: boolean,
   isEnabledAttachTitleHeader: boolean,
   // isEnabledLinebreaks: boolean,
   // isEnabledLinebreaks: boolean,
@@ -246,6 +249,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
 
   useIsUploadAllFileAllowed(props.isUploadAllFileAllowed);
   useIsUploadAllFileAllowed(props.isUploadAllFileAllowed);
   useIsUploadEnabled(props.isUploadEnabled);
   useIsUploadEnabled(props.isUploadEnabled);
+  useIsBulkExportPagesEnabled(props.isBulkExportPagesEnabled);
+  useIsPdfBulkExportEnabled(props.isPdfBulkExportEnabled);
 
 
   useIsLocalAccountRegistrationEnabled(props.isLocalAccountRegistrationEnabled);
   useIsLocalAccountRegistrationEnabled(props.isLocalAccountRegistrationEnabled);
   useIsRomUserAllowedToComment(props.isRomUserAllowedToComment);
   useIsRomUserAllowedToComment(props.isRomUserAllowedToComment);
@@ -592,6 +597,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.disableLinkSharing = configManager.getConfig('security:disableLinkSharing');
   props.disableLinkSharing = configManager.getConfig('security:disableLinkSharing');
   props.isUploadAllFileAllowed = fileUploadService.getFileUploadEnabled();
   props.isUploadAllFileAllowed = fileUploadService.getFileUploadEnabled();
   props.isUploadEnabled = fileUploadService.getIsUploadable();
   props.isUploadEnabled = fileUploadService.getIsUploadable();
+  // TODO: remove growiCloudUri condition when bulk export can be relased for GROWI.cloud (https://redmine.weseek.co.jp/issues/163220)
+  props.isBulkExportPagesEnabled = configManager.getConfig('app:isBulkExportPagesEnabled') && configManager.getConfig('app:growiCloudUri') == null;
+  props.isPdfBulkExportEnabled = configManager.getConfig('app:pageBulkExportPdfConverterUri') != null;
 
 
   props.isLocalAccountRegistrationEnabled = passportService.isLocalStrategySetup
   props.isLocalAccountRegistrationEnabled = passportService.isLocalStrategySetup
   && configManager.getConfig('security:registrationMode') !== RegistrationMode.CLOSED;
   && configManager.getConfig('security:registrationMode') !== RegistrationMode.CLOSED;

+ 17 - 14
apps/app/src/server/crowi/index.js

@@ -11,8 +11,13 @@ import next from 'next';
 import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
 import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
 import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
 import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
 import { startCronIfEnabled as startOpenaiCronIfEnabled } from '~/features/openai/server/services/cron';
 import { startCronIfEnabled as startOpenaiCronIfEnabled } from '~/features/openai/server/services/cron';
+import { checkPageBulkExportJobInProgressCronService } from '~/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron';
+import instanciatePageBulkExportJobCleanUpCronService, {
+  pageBulkExportJobCleanUpCronService,
+} from '~/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron';
+import instanciatePageBulkExportJobCronService from '~/features/page-bulk-export/server/service/page-bulk-export-job-cron';
 import QuestionnaireService from '~/features/questionnaire/server/service/questionnaire';
 import QuestionnaireService from '~/features/questionnaire/server/service/questionnaire';
-import QuestionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
+import questionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
 import { getGrowiVersion } from '~/utils/growi-version';
 import { getGrowiVersion } from '~/utils/growi-version';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 import { projectRoot } from '~/utils/project-dir-utils';
@@ -23,9 +28,11 @@ import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import AppService from '../service/app';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
 import AttachmentService from '../service/attachment';
 import { configManager as configManagerSingletonInstance } from '../service/config-manager';
 import { configManager as configManagerSingletonInstance } from '../service/config-manager';
-import { instanciate as instanciateExternalAccountService } from '../service/external-account';
+import instanciateExportService from '../service/export';
+import instanciateExternalAccountService from '../service/external-account';
 import { FileUploader, getUploader } from '../service/file-uploader'; // eslint-disable-line no-unused-vars
 import { FileUploader, getUploader } from '../service/file-uploader'; // eslint-disable-line no-unused-vars
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
+import GrowiBridgeService from '../service/growi-bridge';
 import { initializeImportService } from '../service/import';
 import { initializeImportService } from '../service/import';
 import { InstallerService } from '../service/installer';
 import { InstallerService } from '../service/installer';
 import { normalizeData } from '../service/normalize-data';
 import { normalizeData } from '../service/normalize-data';
@@ -90,9 +97,6 @@ class Crowi {
   /** @type {QuestionnaireService} */
   /** @type {QuestionnaireService} */
   questionnaireService;
   questionnaireService;
 
 
-  /** @type {QuestionnaireCronService} */
-  questionnaireCronService;
-
   /** @type {import('../service/rest-qiita-API')} */
   /** @type {import('../service/rest-qiita-API')} */
   restQiitaAPIService;
   restQiitaAPIService;
 
 
@@ -134,7 +138,6 @@ class Crowi {
     this.appService = null;
     this.appService = null;
     this.fileUploadService = null;
     this.fileUploadService = null;
     this.growiBridgeService = null;
     this.growiBridgeService = null;
-    this.exportService = null;
     this.pluginService = null;
     this.pluginService = null;
     this.searchService = null;
     this.searchService = null;
     this.socketIoService = null;
     this.socketIoService = null;
@@ -144,7 +147,6 @@ class Crowi {
     this.activityService = null;
     this.activityService = null;
     this.commentService = null;
     this.commentService = null;
     this.questionnaireService = null;
     this.questionnaireService = null;
-    this.questionnaireCronService = null;
     this.openaiThreadDeletionCronService = null;
     this.openaiThreadDeletionCronService = null;
     this.openaiVectorStoreFileDeletionCronService = null;
     this.openaiVectorStoreFileDeletionCronService = null;
 
 
@@ -355,8 +357,13 @@ Crowi.prototype.setupSocketIoService = async function() {
 };
 };
 
 
 Crowi.prototype.setupCron = function() {
 Crowi.prototype.setupCron = function() {
-  this.questionnaireCronService = new QuestionnaireCronService(this);
-  this.questionnaireCronService.startCron();
+  questionnaireCronService.startCron();
+
+  instanciatePageBulkExportJobCronService(this);
+  checkPageBulkExportJobInProgressCronService.startCron();
+
+  instanciatePageBulkExportJobCleanUpCronService(this);
+  pageBulkExportJobCleanUpCronService.startCron();
 
 
   startOpenaiCronIfEnabled();
   startOpenaiCronIfEnabled();
 };
 };
@@ -701,17 +708,13 @@ Crowi.prototype.setupUserGroupService = async function() {
 };
 };
 
 
 Crowi.prototype.setUpGrowiBridge = async function() {
 Crowi.prototype.setUpGrowiBridge = async function() {
-  const GrowiBridgeService = require('../service/growi-bridge');
   if (this.growiBridgeService == null) {
   if (this.growiBridgeService == null) {
     this.growiBridgeService = new GrowiBridgeService(this);
     this.growiBridgeService = new GrowiBridgeService(this);
   }
   }
 };
 };
 
 
 Crowi.prototype.setupExport = async function() {
 Crowi.prototype.setupExport = async function() {
-  const ExportService = require('../service/export');
-  if (this.exportService == null) {
-    this.exportService = new ExportService(this);
-  }
+  instanciateExportService(this);
 };
 };
 
 
 Crowi.prototype.setupImport = async function() {
 Crowi.prototype.setupImport = async function() {

+ 7 - 0
apps/app/src/server/interfaces/attachment.ts

@@ -2,6 +2,7 @@ export const AttachmentType = {
   BRAND_LOGO: 'BRAND_LOGO',
   BRAND_LOGO: 'BRAND_LOGO',
   WIKI_PAGE: 'WIKI_PAGE',
   WIKI_PAGE: 'WIKI_PAGE',
   PROFILE_IMAGE: 'PROFILE_IMAGE',
   PROFILE_IMAGE: 'PROFILE_IMAGE',
+  PAGE_BULK_EXPORT: 'PAGE_BULK_EXPORT',
 } as const;
 } as const;
 
 
 export type AttachmentType = typeof AttachmentType[keyof typeof AttachmentType];
 export type AttachmentType = typeof AttachmentType[keyof typeof AttachmentType];
@@ -29,3 +30,9 @@ export const ResponseMode = {
   DELEGATE: 'delegate',
   DELEGATE: 'delegate',
 } as const;
 } as const;
 export type ResponseMode = typeof ResponseMode[keyof typeof ResponseMode];
 export type ResponseMode = typeof ResponseMode[keyof typeof ResponseMode];
+
+export const FilePathOnStoragePrefix = {
+  attachment: 'attachment',
+  user: 'user',
+  pageBulkExport: 'page-bulk-export',
+} as const;

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

@@ -1,3 +1,4 @@
+import type { Ref, IUser } from '@growi/core';
 import type {
 import type {
   Types, Document, Model, SortOrder,
   Types, Document, Model, SortOrder,
 } from 'mongoose';
 } from 'mongoose';
@@ -21,7 +22,7 @@ const logger = loggerFactory('growi:models:activity');
 
 
 export interface ActivityDocument extends Document {
 export interface ActivityDocument extends Document {
   _id: Types.ObjectId
   _id: Types.ObjectId
-  user: Types.ObjectId
+  user: Ref<IUser>
   ip: string
   ip: string
   endpoint: string
   endpoint: string
   targetModel: SupportedTargetModelType
   targetModel: SupportedTargetModelType

+ 6 - 2
apps/app/src/server/models/attachment.ts

@@ -32,7 +32,9 @@ export interface IAttachmentDocument extends IAttachment, Document {
   cashTemporaryUrlByProvideSec: CashTemporaryUrlByProvideSec,
   cashTemporaryUrlByProvideSec: CashTemporaryUrlByProvideSec,
 }
 }
 export interface IAttachmentModel extends Model<IAttachmentDocument> {
 export interface IAttachmentModel extends Model<IAttachmentDocument> {
-  createWithoutSave
+  createWithoutSave: (
+    pageId, user, originalName: string, fileFormat: string, fileSize: number, attachmentType: AttachmentType,
+  ) => IAttachmentDocument;
 }
 }
 
 
 const attachmentSchema = new Schema({
 const attachmentSchema = new Schema({
@@ -69,7 +71,9 @@ attachmentSchema.set('toObject', { virtuals: true });
 attachmentSchema.set('toJSON', { virtuals: true });
 attachmentSchema.set('toJSON', { virtuals: true });
 
 
 
 
-attachmentSchema.statics.createWithoutSave = function(pageId, user, fileStream, originalName, fileFormat, fileSize, attachmentType) {
+attachmentSchema.statics.createWithoutSave = function(
+    pageId, user, originalName: string, fileFormat: string, fileSize: number, attachmentType: AttachmentType,
+) {
   // eslint-disable-next-line @typescript-eslint/no-this-alias
   // eslint-disable-next-line @typescript-eslint/no-this-alias
   const Attachment = this;
   const Attachment = this;
 
 

+ 8 - 4
apps/app/src/server/models/subscription.ts

@@ -8,7 +8,9 @@ import {
   type Types, type Document, type Model, Schema,
   type Types, type Document, type Model, Schema,
 } from 'mongoose';
 } from 'mongoose';
 
 
-import { AllSupportedTargetModels } from '~/interfaces/activity';
+import type { IPageBulkExportJob } from '~/features/page-bulk-export/interfaces/page-bulk-export';
+import type { SupportedTargetModelType } from '~/interfaces/activity';
+import { AllSupportedTargetModels, SupportedTargetModel } from '~/interfaces/activity';
 
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
@@ -17,7 +19,7 @@ export interface SubscriptionDocument extends ISubscription, Document {}
 
 
 export interface SubscriptionModel extends Model<SubscriptionDocument> {
 export interface SubscriptionModel extends Model<SubscriptionDocument> {
   findByUserIdAndTargetId(userId: Types.ObjectId | string, targetId: Types.ObjectId | string): any
   findByUserIdAndTargetId(userId: Types.ObjectId | string, targetId: Types.ObjectId | string): any
-  upsertSubscription(user: Ref<IUser>, targetModel: string, target: Ref<IPage>, status: string): any
+  upsertSubscription(user: Ref<IUser>, targetModel: SupportedTargetModelType, target: Ref<IPage> | Ref<IUser> | Ref<IPageBulkExportJob>, status: string): any
   subscribeByPageId(userId: Types.ObjectId, pageId: Types.ObjectId, status: string): any
   subscribeByPageId(userId: Types.ObjectId, pageId: Types.ObjectId, status: string): any
   getSubscription(target: Ref<IPage>): Promise<Ref<IUser>[]>
   getSubscription(target: Ref<IPage>): Promise<Ref<IUser>[]>
   getUnsubscription(target: Ref<IPage>): Promise<Ref<IUser>[]>
   getUnsubscription(target: Ref<IPage>): Promise<Ref<IUser>[]>
@@ -63,7 +65,9 @@ subscriptionSchema.statics.findByUserIdAndTargetId = function(userId, targetId)
   return this.findOne({ user: userId, target: targetId });
   return this.findOne({ user: userId, target: targetId });
 };
 };
 
 
-subscriptionSchema.statics.upsertSubscription = function(user, targetModel, target, status) {
+subscriptionSchema.statics.upsertSubscription = function(
+    user: Ref<IUser>, targetModel: SupportedTargetModelType, target: Ref<IPage>, status: SubscriptionStatusType,
+) {
   const query = { user, targetModel, target };
   const query = { user, targetModel, target };
   const doc = { ...query, status };
   const doc = { ...query, status };
   const options = {
   const options = {
@@ -73,7 +77,7 @@ subscriptionSchema.statics.upsertSubscription = function(user, targetModel, targ
 };
 };
 
 
 subscriptionSchema.statics.subscribeByPageId = function(userId, pageId, status) {
 subscriptionSchema.statics.subscribeByPageId = function(userId, pageId, status) {
-  return this.upsertSubscription(userId, 'Page', pageId, status);
+  return this.upsertSubscription(userId, SupportedTargetModel.MODEL_PAGE, pageId, status);
 };
 };
 
 
 subscriptionSchema.statics.getSubscription = async function(target: Ref<IPage>) {
 subscriptionSchema.statics.getSubscription = async function(target: Ref<IPage>) {

+ 0 - 13
apps/app/src/server/models/vo/collection-progress.js

@@ -1,13 +0,0 @@
-class CollectionProgress {
-
-  constructor(collectionName, totalCount) {
-    this.collectionName = collectionName;
-    this.currentCount = 0;
-    this.insertedCount = 0;
-    this.modifiedCount = 0;
-    this.totalCount = totalCount;
-  }
-
-}
-
-module.exports = CollectionProgress;

+ 19 - 0
apps/app/src/server/models/vo/collection-progress.ts

@@ -0,0 +1,19 @@
+class CollectionProgress {
+
+  collectionName: string;
+
+  currentCount = 0;
+
+  insertedCount = 0;
+
+  modifiedCount = 0;
+
+  totalCount = 0;
+
+  constructor(collectionName: string) {
+    this.collectionName = collectionName;
+  }
+
+}
+
+export default CollectionProgress;

+ 12 - 7
apps/app/src/server/models/vo/collection-progressing-status.js → apps/app/src/server/models/vo/collection-progressing-status.ts

@@ -1,13 +1,18 @@
-const CollectionProgress = require('./collection-progress');
+import CollectionProgress from './collection-progress';
 
 
 class CollectionProgressingStatus {
 class CollectionProgressingStatus {
 
 
-  constructor(collections) {
-    this.totalCount = 0;
+  totalCount = 0;
+
+  progressList: CollectionProgress[];
+
+  progressMap: Record<string, CollectionProgress>;
+
+  constructor(collections: string[]) {
     this.progressMap = {};
     this.progressMap = {};
 
 
     this.progressList = collections.map((collectionName) => {
     this.progressList = collections.map((collectionName) => {
-      return new CollectionProgress(collectionName, 0);
+      return new CollectionProgress(collectionName);
     });
     });
 
 
     // collection name to instance mapping
     // collection name to instance mapping
@@ -16,14 +21,14 @@ class CollectionProgressingStatus {
     });
     });
   }
   }
 
 
-  recalculateTotalCount() {
+  recalculateTotalCount(): void {
     this.progressList.forEach((p) => {
     this.progressList.forEach((p) => {
       this.progressMap[p.collectionName] = p;
       this.progressMap[p.collectionName] = p;
       this.totalCount += p.totalCount;
       this.totalCount += p.totalCount;
     });
     });
   }
   }
 
 
-  get currentCount() {
+  get currentCount(): number {
     return this.progressList.reduce(
     return this.progressList.reduce(
       (acc, crr) => acc + crr.currentCount,
       (acc, crr) => acc + crr.currentCount,
       0,
       0,
@@ -32,4 +37,4 @@ class CollectionProgressingStatus {
 
 
 }
 }
 
 
-module.exports = CollectionProgressingStatus;
+export default CollectionProgressingStatus;

+ 3 - 5
apps/app/src/server/routes/admin.js

@@ -1,16 +1,14 @@
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { configManager } from '../service/config-manager';
+import { exportService } from '../service/export';
+
 const logger = loggerFactory('growi:routes:admin');
 const logger = loggerFactory('growi:routes:admin');
 
 
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
-  const {
-    configManager,
-    exportService,
-  } = crowi;
-
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
   const importer = require('../util/importer')(crowi);
   const importer = require('../util/importer')(crowi);
 
 

+ 38 - 1
apps/app/src/server/routes/apiv3/app-settings.js

@@ -404,6 +404,10 @@ module.exports = (crowi) => {
       body('isQuestionnaireEnabled').isBoolean(),
       body('isQuestionnaireEnabled').isBoolean(),
       body('isAppSiteUrlHashed').isBoolean(),
       body('isAppSiteUrlHashed').isBoolean(),
     ],
     ],
+    pageBulkExportSettings: [
+      body('isBulkExportPagesEnabled').isBoolean(),
+      body('bulkExportDownloadExpirationSeconds').isInt(),
+    ],
     maintenanceMode: [
     maintenanceMode: [
       body('flag').isBoolean(),
       body('flag').isBoolean(),
     ],
     ],
@@ -438,6 +442,7 @@ module.exports = (crowi) => {
       globalLang: configManager.getConfig('app:globalLang'),
       globalLang: configManager.getConfig('app:globalLang'),
       isEmailPublishedForNewUser: configManager.getConfig('customize:isEmailPublishedForNewUser'),
       isEmailPublishedForNewUser: configManager.getConfig('customize:isEmailPublishedForNewUser'),
       fileUpload: configManager.getConfig('app:fileUpload'),
       fileUpload: configManager.getConfig('app:fileUpload'),
+      useOnlyEnvVarsForIsBulkExportPagesEnabled: configManager.getConfig('env:useOnlyEnvVars:app:isBulkExportPagesEnabled'),
       isV5Compatible: configManager.getConfig('app:isV5Compatible'),
       isV5Compatible: configManager.getConfig('app:isV5Compatible'),
       siteUrl: configManager.getConfig('app:siteUrl'),
       siteUrl: configManager.getConfig('app:siteUrl'),
       siteUrlUseOnlyEnvVars: configManager.getConfig('env:useOnlyEnvVars:app:siteUrl'),
       siteUrlUseOnlyEnvVars: configManager.getConfig('env:useOnlyEnvVars:app:siteUrl'),
@@ -493,12 +498,17 @@ module.exports = (crowi) => {
       isAppSiteUrlHashed: configManager.getConfig('questionnaire:isAppSiteUrlHashed'),
       isAppSiteUrlHashed: configManager.getConfig('questionnaire:isAppSiteUrlHashed'),
 
 
       isMaintenanceMode: configManager.getConfig('app:isMaintenanceMode'),
       isMaintenanceMode: configManager.getConfig('app:isMaintenanceMode'),
+
+      isBulkExportPagesEnabled: configManager.getConfig('app:isBulkExportPagesEnabled'),
+      envIsBulkExportPagesEnabled: configManager.getConfig('app:isBulkExportPagesEnabled'),
+      bulkExportDownloadExpirationSeconds: configManager.getConfig('app:bulkExportDownloadExpirationSeconds'),
+      // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
+      isBulkExportDisabledForCloud: configManager.getConfig('app:growiCloudUri') != null,
     };
     };
     return res.apiv3({ appSettingsParams });
     return res.apiv3({ appSettingsParams });
 
 
   });
   });
 
 
-
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -1028,6 +1038,33 @@ module.exports = (crowi) => {
 
 
   });
   });
 
 
+  router.put('/page-bulk-export-settings', loginRequiredStrictly, adminRequired, addActivity, validator.pageBulkExportSettings, apiV3FormValidator,
+    async(req, res) => {
+      const requestParams = {
+        'app:isBulkExportPagesEnabled': req.body.isBulkExportPagesEnabled,
+        'app:bulkExportDownloadExpirationSeconds': req.body.bulkExportDownloadExpirationSeconds,
+      };
+
+      try {
+        await configManager.updateConfigs(requestParams, { skipPubsub: true });
+        const responseParams = {
+          isBulkExportPagesEnabled: configManager.getConfig('app:isBulkExportPagesEnabled'),
+          bulkExportDownloadExpirationSeconds: configManager.getConfig('app:bulkExportDownloadExpirationSeconds'),
+        };
+
+        const parameters = { action: SupportedAction.ACTION_ADMIN_APP_SETTINGS_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ responseParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating page bulk export settings';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-page-bulk-export-settings-failed'));
+      }
+
+    });
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *

+ 2 - 1
apps/app/src/server/routes/apiv3/export.js

@@ -1,6 +1,7 @@
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import { SCOPE } from '~/interfaces/scope';
 import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { exportService } from '~/server/service/export';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -122,7 +123,7 @@ module.exports = (crowi) => {
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
 
-  const { exportService, socketIoService } = crowi;
+  const { socketIoService } = crowi;
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
   const adminEvent = crowi.event('admin');
   const adminEvent = crowi.event('admin');

+ 5 - 4
apps/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -11,6 +11,7 @@ import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { isG2GTransferError } from '~/server/models/vo/g2g-transfer-error';
 import { isG2GTransferError } from '~/server/models/vo/g2g-transfer-error';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
+import { exportService } from '~/server/service/export';
 import type { IDataGROWIInfo } from '~/server/service/g2g-transfer';
 import type { IDataGROWIInfo } from '~/server/service/g2g-transfer';
 import { X_GROWI_TRANSFER_KEY_HEADER_NAME } from '~/server/service/g2g-transfer';
 import { X_GROWI_TRANSFER_KEY_HEADER_NAME } from '~/server/service/g2g-transfer';
 import { getImportService } from '~/server/service/import';
 import { getImportService } from '~/server/service/import';
@@ -42,7 +43,7 @@ const validator = {
  */
  */
 module.exports = (crowi: Crowi): Router => {
 module.exports = (crowi: Crowi): Router => {
   const {
   const {
-    g2gTransferPusherService, g2gTransferReceiverService, exportService,
+    g2gTransferPusherService, g2gTransferReceiverService,
     growiBridgeService,
     growiBridgeService,
   } = crowi;
   } = crowi;
 
 
@@ -173,9 +174,9 @@ module.exports = (crowi: Crowi): Router => {
       const zipFile = importService.getFile(file.filename);
       const zipFile = importService.getFile(file.filename);
       await importService.unzip(zipFile);
       await importService.unzip(zipFile);
 
 
-      const { meta: parsedMeta, innerFileStats: _innerFileStats } = await growiBridgeService.parseZipFile(zipFile);
-      innerFileStats = _innerFileStats;
-      meta = parsedMeta;
+      const zipFileStat = await growiBridgeService.parseZipFile(zipFile);
+      innerFileStats = zipFileStat?.innerFileStats;
+      meta = zipFileStat?.meta;
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);

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

@@ -125,6 +125,7 @@ module.exports = (crowi, app) => {
   router.use('/bookmark-folder', require('./bookmark-folder')(crowi));
   router.use('/bookmark-folder', require('./bookmark-folder')(crowi));
   router.use('/questionnaire', require('~/features/questionnaire/server/routes/apiv3/questionnaire')(crowi));
   router.use('/questionnaire', require('~/features/questionnaire/server/routes/apiv3/questionnaire')(crowi));
   router.use('/templates', require('~/features/templates/server/routes/apiv3')(crowi));
   router.use('/templates', require('~/features/templates/server/routes/apiv3')(crowi));
+  router.use('/page-bulk-export', require('~/features/page-bulk-export/server/routes/apiv3/page-bulk-export')(crowi));
 
 
   router.use('/openai', openaiRouteFactory(crowi));
   router.use('/openai', openaiRouteFactory(crowi));
 
 

+ 5 - 1
apps/app/src/server/routes/apiv3/page/index.ts

@@ -27,6 +27,7 @@ import { Revision } from '~/server/models/revision';
 import ShareLink from '~/server/models/share-link';
 import ShareLink from '~/server/models/share-link';
 import Subscription from '~/server/models/subscription';
 import Subscription from '~/server/models/subscription';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
+import { exportService } from '~/server/service/export';
 import type { IPageGrantService } from '~/server/service/page-grant';
 import type { IPageGrantService } from '~/server/service/page-grant';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
@@ -120,7 +121,7 @@ module.exports = (crowi) => {
 
 
   const globalNotificationService = crowi.getGlobalNotificationService();
   const globalNotificationService = crowi.getGlobalNotificationService();
   const Page = mongoose.model<IPage, PageModel>('Page');
   const Page = mongoose.model<IPage, PageModel>('Page');
-  const { pageService, exportService } = crowi;
+  const { pageService } = crowi;
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
@@ -903,6 +904,9 @@ module.exports = (crowi) => {
     let stream: Readable;
     let stream: Readable;
 
 
     try {
     try {
+      if (exportService == null) {
+        throw new Error('exportService is not initialized');
+      }
       stream = exportService.getReadStreamFromRevision(revision, format);
       stream = exportService.getReadStreamFromRevision(revision, format);
     }
     }
     catch (err) {
     catch (err) {

+ 89 - 19
apps/app/src/server/routes/apiv3/personal-setting/index.js

@@ -45,6 +45,8 @@ const router = express.Router();
  *            type: string
  *            type: string
  *          isEmailPublished:
  *          isEmailPublished:
  *            type: boolean
  *            type: boolean
+ *          slackMemberId:
+ *            type: string
  *      Passwords:
  *      Passwords:
  *        description: passwords for update
  *        description: passwords for update
  *        type: object
  *        type: object
@@ -189,6 +191,10 @@ module.exports = (crowi) => {
    *                  properties:
    *                  properties:
    *                    isPasswordSet:
    *                    isPasswordSet:
    *                      type: boolean
    *                      type: boolean
+   *                      description: Whether a password has been set
+   *                    minPasswordLength:
+   *                      type: number
+   *                      description: Minimum password length
    */
    */
   router.get('/is-password-set', accessTokenParser([SCOPE.READ.USER_SETTINGS.PASSWORD]), loginRequiredStrictly, async(req, res) => {
   router.get('/is-password-set', accessTokenParser([SCOPE.READ.USER_SETTINGS.PASSWORD]), loginRequiredStrictly, async(req, res) => {
     const { username } = req.user;
     const { username } = req.user;
@@ -228,7 +234,7 @@ module.exports = (crowi) => {
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
    *                  properties:
    *                  properties:
-   *                    currentUser:
+   *                    updatedUser:
    *                      type: object
    *                      type: object
    *                      description: personal params
    *                      description: personal params
    */
    */
@@ -272,6 +278,13 @@ module.exports = (crowi) => {
    *        operationId: putUserImageType
    *        operationId: putUserImageType
    *        summary: /personal-setting/image-type
    *        summary: /personal-setting/image-type
    *        description: Update user image type
    *        description: Update user image type
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              properties:
+   *                isGravatarEnabled:
+   *                  type: boolean
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: succeded to update user image type
    *            description: succeded to update user image type
@@ -349,8 +362,11 @@ module.exports = (crowi) => {
    *          required: true
    *          required: true
    *          content:
    *          content:
    *            application/json:
    *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/Passwords'
+   *              properties:
+   *                oldPassword:
+   *                  type: string
+   *                newPassword:
+   *                  type: string
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: user password
    *            description: user password
@@ -391,6 +407,8 @@ module.exports = (crowi) => {
    *    /personal-setting/api-token:
    *    /personal-setting/api-token:
    *      put:
    *      put:
    *        tags: [GeneralSetting]
    *        tags: [GeneralSetting]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: putUserApiToken
    *        operationId: putUserApiToken
    *        summary: /personal-setting/api-token
    *        summary: /personal-setting/api-token
    *        description: Update user api token
    *        description: Update user api token
@@ -520,7 +538,9 @@ module.exports = (crowi) => {
    *          content:
    *          content:
    *            application/json:
    *            application/json:
    *              schema:
    *              schema:
-   *                $ref: '#/components/schemas/AssociateUser'
+   *                properties:
+   *                  username:
+   *                    type: string
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: succeded to associate Ldap account
    *            description: succeded to associate Ldap account
@@ -619,18 +639,30 @@ module.exports = (crowi) => {
    *      put:
    *      put:
    *        tags: [EditorSetting]
    *        tags: [EditorSetting]
    *        operationId: putEditorSettings
    *        operationId: putEditorSettings
-   *        summary: /editor-setting
+   *        summary: /personal-setting/editor-settings
    *        description: Put editor preferences
    *        description: Put editor preferences
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  theme:
+   *                    type: string
+   *                  keymapMode:
+   *                    type: string
+   *                  styleActiveLine:
+   *                    type: boolean
+   *                  autoFormatMarkdownTable:
+   *                    type: boolean
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: params of editor settings
    *            description: params of editor settings
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
-   *                  properties:
-   *                    currentUser:
-   *                      type: object
-   *                      description: editor settings
+   *                  type: object
+   *                  description: editor settings
    */
    */
   router.put('/editor-settings', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.OTHER]), loginRequiredStrictly, addActivity, validator.editorSettings, apiV3FormValidator,
   router.put('/editor-settings', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.OTHER]), loginRequiredStrictly, addActivity, validator.editorSettings, apiV3FormValidator,
     async(req, res) => {
     async(req, res) => {
@@ -670,7 +702,7 @@ module.exports = (crowi) => {
    *      get:
    *      get:
    *        tags: [EditorSetting]
    *        tags: [EditorSetting]
    *        operationId: getEditorSettings
    *        operationId: getEditorSettings
-   *        summary: /editor-setting
+   *        summary: /personal-setting/editor-settings
    *        description: Get editor preferences
    *        description: Get editor preferences
    *        responses:
    *        responses:
    *          200:
    *          200:
@@ -678,10 +710,8 @@ module.exports = (crowi) => {
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
-   *                  properties:
-   *                    currentUser:
-   *                      type: object
-   *                      description: editor settings
+   *                  type: object
+   *                  description: editor settings
    */
    */
   router.get('/editor-settings', accessTokenParser([SCOPE.READ.USER_SETTINGS.OTHER]), loginRequiredStrictly, async(req, res) => {
   router.get('/editor-settings', accessTokenParser([SCOPE.READ.USER_SETTINGS.OTHER]), loginRequiredStrictly, async(req, res) => {
     try {
     try {
@@ -702,18 +732,30 @@ module.exports = (crowi) => {
    *      put:
    *      put:
    *        tags: [InAppNotificationSettings]
    *        tags: [InAppNotificationSettings]
    *        operationId: putInAppNotificationSettings
    *        operationId: putInAppNotificationSettings
-   *        summary: personal-setting/in-app-notification-settings
+   *        summary: /personal-setting/in-app-notification-settings
    *        description: Put InAppNotificationSettings
    *        description: Put InAppNotificationSettings
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  subscribeRules:
+   *                    type: array
+   *                    items:
+   *                      type: object
+   *                      properties:
+   *                        name:
+   *                          type: string
+   *                        isEnabled:
+   *                          type: boolean
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: params of InAppNotificationSettings
    *            description: params of InAppNotificationSettings
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
-   *                  properties:
-   *                    currentUser:
-   *                      type: object
-   *                      description: in-app-notification-settings
+   *                 type: object
    */
    */
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
   router.put('/in-app-notification-settings', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION]), loginRequiredStrictly, addActivity, validator.inAppNotificationSettings, apiV3FormValidator, async(req, res) => {
   router.put('/in-app-notification-settings', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION]), loginRequiredStrictly, addActivity, validator.inAppNotificationSettings, apiV3FormValidator, async(req, res) => {
@@ -771,6 +813,34 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
+  /**
+   * @swagger
+   *   /personal-setting/questionnaire-settings:
+   *     put:
+   *       tags: [QuestionnaireSetting]
+   *       operationId: putQuestionnaireSetting
+   *       summary: /personal-setting/questionnaire-settings
+   *       description: Update the questionnaire settings for the current user
+   *       requestBody:
+   *         required: true
+   *         content:
+   *           application/json:
+   *             schema:
+   *               properties:
+   *                 isQuestionnaireEnabled:
+   *                   type: boolean
+   *       responses:
+   *         200:
+   *           description: Successfully updated questionnaire settings
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 properties:
+   *                   message:
+   *                     type: string
+   *                   isQuestionnaireEnabled:
+   *                     type: boolean
+   */
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
   router.put('/questionnaire-settings', accessTokenParser([SCOPE.WRITE.FEATURES.QUESTIONNAIRE]), loginRequiredStrictly, validator.questionnaireSettings, apiV3FormValidator, async(req, res) => {
   router.put('/questionnaire-settings', accessTokenParser([SCOPE.WRITE.FEATURES.QUESTIONNAIRE]), loginRequiredStrictly, validator.questionnaireSettings, apiV3FormValidator, async(req, res) => {
     const { isQuestionnaireEnabled } = req.body;
     const { isQuestionnaireEnabled } = req.body;

+ 171 - 9
apps/app/src/server/routes/apiv3/security-settings/index.js

@@ -125,6 +125,18 @@ const validator = {
  *          restrictGuestMode:
  *          restrictGuestMode:
  *            type: string
  *            type: string
  *            description: type of restrictGuestMode
  *            description: type of restrictGuestMode
+ *          pageDeletionAuthority:
+ *            type: string
+ *            description: type of pageDeletionAuthority
+ *          pageRecursiveDeletionAuthority:
+ *            type: string
+ *            description: type of pageRecursiveDeletionAuthority
+ *          pageRecursiveCompleteDeletionAuthority:
+ *            type: string
+ *            description: type of pageRecursiveCompleteDeletionAuthority
+ *          isAllGroupMembershipRequiredForPageCompleteDeletion:
+ *            type: boolean
+ *            description: enable all group membership required for page complete deletion
  *          pageCompleteDeletionAuthority:
  *          pageCompleteDeletionAuthority:
  *            type: string
  *            type: string
  *            description: type of pageDeletionAuthority
  *            description: type of pageDeletionAuthority
@@ -134,6 +146,21 @@ const validator = {
  *          hideRestrictedByGroup:
  *          hideRestrictedByGroup:
  *            type: boolean
  *            type: boolean
  *            description: enable hide by group
  *            description: enable hide by group
+ *          isUsersHomepageDeletionEnabled:
+ *            type: boolean
+ *            description: enable user homepage deletion
+ *          isForceDeleteUserHomepageOnUserDeletion:
+ *            type: boolean
+ *            description: enable force delete user homepage on user deletion
+ *          isRomUserAllowedToComment:
+ *            type: boolean
+ *            description: enable rom user allowed to comment
+ *          wikiMode:
+ *            type: string
+ *            description: type of wikiMode
+ *          sessionMaxAge:
+ *            type: integer
+ *            description: max age of session
  *      ShareLinkSetting:
  *      ShareLinkSetting:
  *        type: object
  *        type: object
  *        properties:
  *        properties:
@@ -143,6 +170,15 @@ const validator = {
  *      LocalSetting:
  *      LocalSetting:
  *        type: object
  *        type: object
  *        properties:
  *        properties:
+ *          useOnlyEnvVarsForSomeOptions:
+ *            type: boolean
+ *            description: use only env vars for some options
+ *          isPasswordResetEnabled:
+ *            type: boolean
+ *            description: enable password reset
+ *          isEmailAuthenticationEnabled:
+ *            type: boolean
+ *            description: enable email authentication
  *          isLocalEnabled:
  *          isLocalEnabled:
  *            type: boolean
  *            type: boolean
  *            description: local setting mode
  *            description: local setting mode
@@ -155,6 +191,27 @@ const validator = {
  *            items:
  *            items:
  *              type: string
  *              type: string
  *              description: registration whitelist
  *              description: registration whitelist
+ *      GeneralAuthSetting:
+ *        type: object
+ *        properties:
+ *          isLocalEnabled:
+ *            type: boolean
+ *            description: local setting mode
+ *          isLdapEnabled:
+ *            type: boolean
+ *            description: ldap setting mode
+ *          isSamlEnabled:
+ *            type: boolean
+ *            description: saml setting mode
+ *          isOidcEnabled:
+ *            type: boolean
+ *            description: oidc setting mode
+ *          isGoogleEnabled:
+ *            type: boolean
+ *            description: google setting mode
+ *          isGitHubEnabled:
+ *            type: boolean
+ *            description: github setting mode
  *      LdapAuthSetting:
  *      LdapAuthSetting:
  *        type: object
  *        type: object
  *        properties:
  *        properties:
@@ -197,15 +254,30 @@ const validator = {
  *      SamlAuthSetting:
  *      SamlAuthSetting:
  *        type: object
  *        type: object
  *        properties:
  *        properties:
+ *          missingMandatoryConfigKeys:
+ *            type: array
+ *            description: array of missing mandatory config keys
+ *            items:
+ *              type: string
+ *              description: missing mandatory config key
+ *          useOnlyEnvVarsForSomeOptions:
+ *            type: boolean
+ *            description: use only env vars for some options
  *          samlEntryPoint:
  *          samlEntryPoint:
  *            type: string
  *            type: string
  *            description: entry point for saml
  *            description: entry point for saml
  *          samlIssuer:
  *          samlIssuer:
  *            type: string
  *            type: string
  *            description: issuer for saml
  *            description: issuer for saml
+ *          samlEnvVarIssuer:
+ *            type: string
+ *            description: issuer for saml
  *          samlCert:
  *          samlCert:
  *            type: string
  *            type: string
  *            description: certificate for saml
  *            description: certificate for saml
+ *          samlEnvVarCert:
+ *            type: string
+ *            desription: certificate for saml
  *          samlAttrMapId:
  *          samlAttrMapId:
  *            type: string
  *            type: string
  *            description: attribute mapping id for saml
  *            description: attribute mapping id for saml
@@ -215,12 +287,27 @@ const validator = {
  *          samlAttrMapMail:
  *          samlAttrMapMail:
  *            type: string
  *            type: string
  *            description: attribute mapping mail for saml
  *            description: attribute mapping mail for saml
+ *          samlEnvVarAttrMapId:
+ *            type: string
+ *            description: attribute mapping id for saml
+ *          samlEnvVarAttrMapUserName:
+ *            type: string
+ *            description: attribute mapping user name for saml
+ *          samlEnvVarAttrMapMail:
+ *            type: string
+ *            description: attribute mapping mail for saml
  *          samlAttrMapFirstName:
  *          samlAttrMapFirstName:
  *            type: string
  *            type: string
  *            description: attribute mapping first name for saml
  *            description: attribute mapping first name for saml
  *          samlAttrMapLastName:
  *          samlAttrMapLastName:
  *            type: string
  *            type: string
  *            description: attribute mapping last name for saml
  *            description: attribute mapping last name for saml
+ *          samlEnvVarAttrMapFirstName:
+ *            type: string
+ *            description: attribute mapping first name for saml
+ *          samlEnvVarAttrMapLastName:
+ *            type: string
+ *            description: attribute mapping last name for saml
  *          isSameUsernameTreatedAsIdenticalUser:
  *          isSameUsernameTreatedAsIdenticalUser:
  *            type: boolean
  *            type: boolean
  *            description: local account automatically linked the user name matched
  *            description: local account automatically linked the user name matched
@@ -230,6 +317,9 @@ const validator = {
  *          samlABLCRule:
  *          samlABLCRule:
  *            type: string
  *            type: string
  *            description: ABLCRule for saml
  *            description: ABLCRule for saml
+ *          samlEnvVarABLCRule:
+ *            type: string
+ *            description: ABLCRule for saml
  *      OidcAuthSetting:
  *      OidcAuthSetting:
  *        type: object
  *        type: object
  *        properties:
  *        properties:
@@ -347,6 +437,25 @@ module.exports = (crowi) => {
    *                    securityParams:
    *                    securityParams:
    *                      type: object
    *                      type: object
    *                      description: security params
    *                      description: security params
+   *                      properties:
+   *                        generalSetting:
+   *                          $ref: '#/components/schemas/GeneralSetting'
+   *                        shareLinkSetting:
+   *                          $ref: '#/components/schemas/ShareLinkSetting'
+   *                        localSetting:
+   *                          $ref: '#/components/schemas/LocalSetting'
+   *                        generalAuth:
+   *                          $ref: '#/components/schemas/GeneralAuthSetting'
+   *                        ldapAuth:
+   *                          $ref: '#/components/schemas/LdapAuthSetting'
+   *                        samlAuth:
+   *                          $ref: '#/components/schemas/SamlAuthSetting'
+   *                        oidcAuth:
+   *                          $ref: '#/components/schemas/OidcAuthSetting'
+   *                        googleOAuth:
+   *                          $ref: '#/components/schemas/GoogleOAuthSetting'
+   *                        githubOAuth:
+   *                          $ref: '#/components/schemas/GitHubOAuth
    */
    */
   router.get('/', accessTokenParser([SCOPE.READ.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, async(req, res) => {
   router.get('/', accessTokenParser([SCOPE.READ.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, async(req, res) => {
 
 
@@ -474,7 +583,7 @@ module.exports = (crowi) => {
    *                properties:
    *                properties:
    *                  isEnabled:
    *                  isEnabled:
    *                    type: boolean
    *                    type: boolean
-   *                  target:
+   *                  authId:
    *                    type: string
    *                    type: string
    *        responses:
    *        responses:
    *          200:
    *          200:
@@ -579,6 +688,9 @@ module.exports = (crowi) => {
    *    /security-setting/authentication:
    *    /security-setting/authentication:
    *      get:
    *      get:
    *        tags: [SecuritySetting]
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/authentication
    *        description: Get setup strategies for passport
    *        description: Get setup strategies for passport
    *        responses:
    *        responses:
    *          200:
    *          200:
@@ -607,6 +719,9 @@ module.exports = (crowi) => {
    *    /security-setting/general-setting:
    *    /security-setting/general-setting:
    *      put:
    *      put:
    *        tags: [SecuritySetting]
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/general-setting
    *        description: Update GeneralSetting
    *        description: Update GeneralSetting
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
@@ -695,6 +810,9 @@ module.exports = (crowi) => {
    *    /security-setting/share-link-setting:
    *    /security-setting/share-link-setting:
    *      put:
    *      put:
    *        tags: [SecuritySetting]
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/share-link-setting
    *        description: Update ShareLink Setting
    *        description: Update ShareLink Setting
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
@@ -708,7 +826,9 @@ module.exports = (crowi) => {
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
-   *                  $ref: '#/components/schemas/ShareLinkSetting'
+   *                  properties:
+   *                    securitySettingParams:
+   *                      $ref: '#/components/schemas/ShareLinkSetting'
    */
    */
   router.put('/share-link-setting', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
   router.put('/share-link-setting', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
     validator.generalSetting, apiV3FormValidator,
     validator.generalSetting, apiV3FormValidator,
@@ -740,6 +860,9 @@ module.exports = (crowi) => {
    *    /security-setting/all-share-links:
    *    /security-setting/all-share-links:
    *      get:
    *      get:
    *        tags: [SecuritySetting]
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/all-share-links
    *        description: Get All ShareLinks at Share Link Setting
    *        description: Get All ShareLinks at Share Link Setting
    *        responses:
    *        responses:
    *          200:
    *          200:
@@ -783,12 +906,21 @@ module.exports = (crowi) => {
    *    /security-setting/all-share-links:
    *    /security-setting/all-share-links:
    *      delete:
    *      delete:
    *        tags: [SecuritySetting]
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/all-share-links
    *        description: Delete All ShareLinks at Share Link Setting
    *        description: Delete All ShareLinks at Share Link Setting
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: succeed to delete all share links
    *            description: succeed to delete all share links
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    removeTotal:
+   *                      type: number
+   *                      description: total number of removed share links
    */
    */
-
   router.delete('/all-share-links/', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, async(req, res) => {
   router.delete('/all-share-links/', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, async(req, res) => {
     try {
     try {
       const removedAct = await ShareLink.remove({});
       const removedAct = await ShareLink.remove({});
@@ -808,6 +940,9 @@ module.exports = (crowi) => {
    *    /security-setting/local-setting:
    *    /security-setting/local-setting:
    *      put:
    *      put:
    *        tags: [SecuritySetting]
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/local-setting
    *        description: Update LocalSetting
    *        description: Update LocalSetting
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
@@ -821,7 +956,9 @@ module.exports = (crowi) => {
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
-   *                  $ref: '#/components/schemas/LocalSetting'
+   *                  properties:
+   *                    localSettingParams:
+   *                      $ref: '#/components/schemas/LocalSetting'
    */
    */
   router.put('/local-setting', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
   router.put('/local-setting', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
     validator.localSetting, apiV3FormValidator,
     validator.localSetting, apiV3FormValidator,
@@ -862,6 +999,9 @@ module.exports = (crowi) => {
    *    /security-setting/ldap:
    *    /security-setting/ldap:
    *      put:
    *      put:
    *        tags: [SecuritySetting]
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/ldap
    *        description: Update LDAP setting
    *        description: Update LDAP setting
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
@@ -875,7 +1015,9 @@ module.exports = (crowi) => {
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
-   *                  $ref: '#/components/schemas/LdapAuthSetting'
+   *                  properties:
+   *                    securitySettingParams:
+   *                      $ref: '#/components/schemas/LdapAuthSetting'
    */
    */
   router.put('/ldap', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
   router.put('/ldap', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
     validator.ldapAuth, apiV3FormValidator,
     validator.ldapAuth, apiV3FormValidator,
@@ -929,6 +1071,9 @@ module.exports = (crowi) => {
    *    /security-setting/saml:
    *    /security-setting/saml:
    *      put:
    *      put:
    *        tags: [SecuritySetting]
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/saml
    *        description: Update SAML setting
    *        description: Update SAML setting
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
@@ -942,7 +1087,9 @@ module.exports = (crowi) => {
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
-   *                  $ref: '#/components/schemas/SamlAuthSetting'
+   *                  properties:
+   *                    securitySettingParams:
+   *                      $ref: '#/components/schemas/SamlAuthSetting'
    */
    */
   router.put('/saml', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
   router.put('/saml', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
     validator.samlAuth, apiV3FormValidator,
     validator.samlAuth, apiV3FormValidator,
@@ -1025,6 +1172,9 @@ module.exports = (crowi) => {
    *    /security-setting/oidc:
    *    /security-setting/oidc:
    *      put:
    *      put:
    *        tags: [SecuritySetting]
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/oidc
    *        description: Update OpenID Connect setting
    *        description: Update OpenID Connect setting
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
@@ -1038,7 +1188,9 @@ module.exports = (crowi) => {
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
-   *                  $ref: '#/components/schemas/OidcAuthSetting'
+   *                  properties:
+   *                    securitySettingParams:
+   *                      $ref: '#/components/schemas/OidcAuthSetting'
    */
    */
   router.put('/oidc', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
   router.put('/oidc', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
     validator.oidcAuth, apiV3FormValidator,
     validator.oidcAuth, apiV3FormValidator,
@@ -1104,6 +1256,9 @@ module.exports = (crowi) => {
    *    /security-setting/google-oauth:
    *    /security-setting/google-oauth:
    *      put:
    *      put:
    *        tags: [SecuritySetting]
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/google-oauth
    *        description: Update google OAuth
    *        description: Update google OAuth
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
@@ -1117,7 +1272,9 @@ module.exports = (crowi) => {
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
-   *                  $ref: '#/components/schemas/GoogleOAuthSetting'
+   *                  properties:
+   *                    securitySettingParams:
+   *                      $ref: '#/components/schemas/GoogleOAuthSetting'
    */
    */
   router.put('/google-oauth', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
   router.put('/google-oauth', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
     validator.googleOAuth, apiV3FormValidator,
     validator.googleOAuth, apiV3FormValidator,
@@ -1154,6 +1311,9 @@ module.exports = (crowi) => {
    *    /security-setting/github-oauth:
    *    /security-setting/github-oauth:
    *      put:
    *      put:
    *        tags: [SecuritySetting]
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/github-oauth
    *        description: Update github OAuth
    *        description: Update github OAuth
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
@@ -1167,7 +1327,9 @@ module.exports = (crowi) => {
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
-   *                  $ref: '#/components/schemas/GitHubOAuthSetting'
+   *                  properties:
+   *                    securitySettingParams:
+   *                      $ref: '#/components/schemas/GitHubOAuthSetting'
    */
    */
   router.put('/github-oauth', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
   router.put('/github-oauth', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
     validator.githubOAuth, apiV3FormValidator,
     validator.githubOAuth, apiV3FormValidator,

+ 181 - 11
apps/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -163,11 +163,39 @@ module.exports = (crowi) => {
    *      get:
    *      get:
    *        tags: [SlackIntegrationSettings]
    *        tags: [SlackIntegrationSettings]
    *        operationId: getSlackBotSettingParams
    *        operationId: getSlackBotSettingParams
-   *        summary: /slack-integration
+   *        summary: /slack-integration-settings
    *        description: Get current settings and connection statuses.
    *        description: Get current settings and connection statuses.
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: Succeeded to get info.
    *            description: Succeeded to get info.
+   *            content:
+   *              application/json:
+   *                properties:
+   *                  currentBotType:
+   *                    type: string
+   *                  settings:
+   *                    type: object
+   *                    properties:
+   *                      slackSigningSecretEnvVars:
+   *                        type: string
+   *                      slackBotTokenEnvVars:
+   *                        type: string
+   *                      slackSigningSecret:
+   *                        type: string
+   *                      slackBotToken:
+   *                        type: string
+   *                      commandPermission:
+   *                        type: object
+   *                      eventActionsPermission:
+   *                        type: object
+   *                      proxyServerUri:
+   *                        type: string
+   *                  connectionStatuses:
+   *                    type: object
+   *                  errorMsg:
+   *                    type: string
+   *                  errorCode:
+   *                    type: string
    */
    */
   router.get('/', accessTokenParser([SCOPE.READ.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, async(req, res) => {
   router.get('/', accessTokenParser([SCOPE.READ.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, async(req, res) => {
 
 
@@ -362,9 +390,21 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/without-proxy/update-settings/:
    *    /slack-integration-settings/without-proxy/update-settings/:
    *      put:
    *      put:
    *        tags: [SlackIntegrationSettings (without proxy)]
    *        tags: [SlackIntegrationSettings (without proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: putWithoutProxySettings
    *        operationId: putWithoutProxySettings
-   *        summary: update customBotWithoutProxy settings
+   *        summary: /slack-integration-settings/without-proxy/update-settings
    *        description: Update customBotWithoutProxy setting.
    *        description: Update customBotWithoutProxy setting.
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  slackSigningSecret:
+   *                    type: string
+   *                  slackBotToken:
+   *                    type: string
    *        responses:
    *        responses:
    *           200:
    *           200:
    *             description: Succeeded to put CustomBotWithoutProxy setting.
    *             description: Succeeded to put CustomBotWithoutProxy setting.
@@ -403,9 +443,21 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/without-proxy/update-permissions/:
    *    /slack-integration-settings/without-proxy/update-permissions/:
    *      put:
    *      put:
    *        tags: [SlackIntegrationSettings (without proxy)]
    *        tags: [SlackIntegrationSettings (without proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: putWithoutProxyPermissions
    *        operationId: putWithoutProxyPermissions
-   *        summary: update customBotWithoutProxy permissions
+   *        summary: /slack-integration-settings/without-proxy/update-permissions
    *        description: Update customBotWithoutProxy permissions.
    *        description: Update customBotWithoutProxy permissions.
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  commandPermission:
+   *                    type: object
+   *                  eventActionsPermission:
+   *                    type: object
    *        responses:
    *        responses:
    *           200:
    *           200:
    *             description: Succeeded to put CustomBotWithoutProxy permissions.
    *             description: Succeeded to put CustomBotWithoutProxy permissions.
@@ -446,12 +498,29 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/slack-app-integrations:
    *    /slack-integration-settings/slack-app-integrations:
    *      post:
    *      post:
    *        tags: [SlackIntegrationSettings (with proxy)]
    *        tags: [SlackIntegrationSettings (with proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: putSlackAppIntegrations
    *        operationId: putSlackAppIntegrations
-   *        summary: /slack-integration
+   *        summary: /slack-integration-settings/slack-app-integrations
    *        description: Generate SlackAppIntegrations
    *        description: Generate SlackAppIntegrations
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: Succeeded to create slack app integration
    *            description: Succeeded to create slack app integration
+   *            content:
+   *              application/json:
+   *                properties:
+   *                  tokenGtoP:
+   *                    type: string
+   *                  tokenPtoG:
+   *                    type: string
+   *                  permissionsForBroadcastUseCommands:
+   *                    type: object
+   *                  permissionsForSingleUseCommands:
+   *                    type: object
+   *                  permissionsForSlackEvents:
+   *                    type: object
+   *                  isPrimary:
+   *                    type: boolean
    */
    */
   router.post('/slack-app-integrations', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity,
   router.post('/slack-app-integrations', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity,
     async(req, res) => {
     async(req, res) => {
@@ -497,12 +566,25 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/slack-app-integrations/:id:
    *    /slack-integration-settings/slack-app-integrations/:id:
    *      delete:
    *      delete:
    *        tags: [SlackIntegrationSettings (with proxy)]
    *        tags: [SlackIntegrationSettings (with proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: deleteAccessTokens
    *        operationId: deleteAccessTokens
-   *        summary: delete accessTokens
+   *        summary: /slack-integration-settings/slack-app-integrations/:id
    *        description: Delete accessTokens
    *        description: Delete accessTokens
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            schema:
+   *              type: string
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: Succeeded to delete access tokens for slack
    *            description: Succeeded to delete access tokens for slack
+   *            content:
+   *              application/json:
+   *                properties:
+   *                  response:
+   *                    type: object
    */
    */
   router.delete('/slack-app-integrations/:id', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired,
   router.delete('/slack-app-integrations/:id', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired,
     validator.deleteIntegration, apiV3FormValidator, addActivity,
     validator.deleteIntegration, apiV3FormValidator, addActivity,
@@ -529,6 +611,32 @@ module.exports = (crowi) => {
       }
       }
     });
     });
 
 
+  /**
+   * @swagger
+   *   /slack-integration-settings/proxy-uri:
+   *     put:
+   *       tags: [SlackIntegrationSettings (with proxy)]
+   *       security:
+   *         - cookieAuth: []
+   *       operationId: putProxyUri
+   *       summary: /slack-integration-settings/proxy-uri
+   *       description: Update proxy uri
+   *       requestBody:
+   *         required: true
+   *         content:
+   *           application/json:
+   *             schema:
+   *               properties:
+   *                 proxyUri:
+   *                   type: string
+   *       responses:
+   *         200:
+   *           description: Succeeded to update proxy uri
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   */
   router.put('/proxy-uri', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity,
   router.put('/proxy-uri', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity,
     validator.proxyUri, apiV3FormValidator,
     validator.proxyUri, apiV3FormValidator,
     async(req, res) => {
     async(req, res) => {
@@ -558,9 +666,17 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/slack-app-integrations/:id/makeprimary:
    *    /slack-integration-settings/slack-app-integrations/:id/makeprimary:
    *      put:
    *      put:
    *        tags: [SlackIntegrationSettings (with proxy)]
    *        tags: [SlackIntegrationSettings (with proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: makePrimary
    *        operationId: makePrimary
-   *        summary: /slack-integration
+   *        summary: /slack-integration-settings/slack-app-integrations/:id/makeprimary
    *        description: Make SlackAppTokens primary
    *        description: Make SlackAppTokens primary
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            schema:
+   *              type: string
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: Succeeded to make it primary
    *            description: Succeeded to make it primary
@@ -605,12 +721,24 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/slack-app-integrations/:id/regenerate-tokens:
    *    /slack-integration-settings/slack-app-integrations/:id/regenerate-tokens:
    *      put:
    *      put:
    *        tags: [SlackIntegrationSettings (with proxy)]
    *        tags: [SlackIntegrationSettings (with proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: putRegenerateTokens
    *        operationId: putRegenerateTokens
-   *        summary: /slack-integration
+   *        summary: /slack-integration-settings/slack-app-integrations/:id/regenerate-tokens
    *        description: Regenerate SlackAppTokens
    *        description: Regenerate SlackAppTokens
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            schema:
+   *              type: string
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: Succeeded to regenerate slack app tokens
    *            description: Succeeded to regenerate slack app tokens
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
    */
    */
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
   router.put('/slack-app-integrations/:id/regenerate-tokens', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity, validator.regenerateTokens, apiV3FormValidator, async(req, res) => {
   router.put('/slack-app-integrations/:id/regenerate-tokens', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity, validator.regenerateTokens, apiV3FormValidator, async(req, res) => {
@@ -638,12 +766,36 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/slack-app-integrations/:id/permissions:
    *    /slack-integration-settings/slack-app-integrations/:id/permissions:
    *      put:
    *      put:
    *        tags: [SlackIntegrationSettings (with proxy)]
    *        tags: [SlackIntegrationSettings (with proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: putSupportedCommands
    *        operationId: putSupportedCommands
-   *        summary: /slack-integration-settings/:id/permissions
+   *        summary: /slack-integration-settings/slack-app-integrations/:id/permissions
    *        description: update supported commands
    *        description: update supported commands
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            schema:
+   *              type: string
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  permissionsForBroadcastUseCommands:
+   *                    type: object
+   *                  permissionsForSingleUseCommands:
+   *                    type: object
+   *                  permissionsForSlackEventActions:
+   *                    type: object
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: Succeeded to update supported commands
    *            description: Succeeded to update supported commands
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
    */
    */
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
   router.put('/slack-app-integrations/:id/permissions', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity, validator.updatePermissionsWithProxy, apiV3FormValidator, async(req, res) => {
   router.put('/slack-app-integrations/:id/permissions', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity, validator.updatePermissionsWithProxy, apiV3FormValidator, async(req, res) => {
@@ -697,9 +849,25 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/slack-app-integrations/:id/relation-test:
    *    /slack-integration-settings/slack-app-integrations/:id/relation-test:
    *      post:
    *      post:
    *        tags: [SlackIntegrationSettings (with proxy)]
    *        tags: [SlackIntegrationSettings (with proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: postRelationTest
    *        operationId: postRelationTest
-   *        summary: Test relation
+   *        summary: /slack-integration-settings/slack-app-integrations/:id/relation-test
    *        description: Delete botType setting.
    *        description: Delete botType setting.
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            schema:
+   *              type: string
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  channel:
+   *                    type: string
    *        responses:
    *        responses:
    *           200:
    *           200:
    *             description: Succeeded to delete botType setting.
    *             description: Succeeded to delete botType setting.
@@ -768,15 +936,17 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/without-proxy/test:
    *    /slack-integration-settings/without-proxy/test:
    *      post:
    *      post:
    *        tags: [SlackIntegrationSettings (without proxy)]
    *        tags: [SlackIntegrationSettings (without proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: postTest
    *        operationId: postTest
-   *        summary: test the connection
+   *        summary: /slack-integration-settings/without-proxy/test
    *        description: Test the connection with slack work space.
    *        description: Test the connection with slack work space.
    *        requestBody:
    *        requestBody:
    *          content:
    *          content:
    *            application/json:
    *            application/json:
    *              schema:
    *              schema:
    *                properties:
    *                properties:
-   *                  testChannel:
+   *                  channel:
    *                    type: string
    *                    type: string
    *        responses:
    *        responses:
    *           200:
    *           200:

+ 183 - 24
apps/app/src/server/routes/apiv3/user-group.js

@@ -91,9 +91,36 @@ module.exports = (crowi) => {
    *    /user-groups:
    *    /user-groups:
    *      get:
    *      get:
    *        tags: [UserGroups]
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getUserGroup
    *        operationId: getUserGroup
    *        summary: /user-groups
    *        summary: /user-groups
    *        description: Get usergroups
    *        description: Get usergroups
+   *        parameters:
+   *          - name: page
+   *            in: query
+   *            required: false
+   *            description: page number
+   *            schema:
+   *              type: number
+   *          - name: limit
+   *            in: query
+   *            required: false
+   *            description: number of items per page
+   *            schema:
+   *              type: number
+   *          - name: offset
+   *            in: query
+   *            required: false
+   *            description: offset
+   *            schema:
+   *              type: number
+   *          - name: pagination
+   *            in: query
+   *            required: false
+   *            description: whether to paginate
+   *            schema:
+   *              type: boolean
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: usergroups are fetched
    *            description: usergroups are fetched
@@ -104,6 +131,12 @@ module.exports = (crowi) => {
    *                    userGroups:
    *                    userGroups:
    *                      type: object
    *                      type: object
    *                      description: a result of `UserGroup.find`
    *                      description: a result of `UserGroup.find`
+   *                    totalUserGroups:
+   *                      type: number
+   *                      description: the number of userGroups
+   *                    pagingLimit:
+   *                      type: number
+   *                      description: the number of items per page
    */
    */
   router.get('/', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, async(req, res) => {
   router.get('/', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, async(req, res) => {
     const { query } = req;
     const { query } = req;
@@ -131,11 +164,13 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *  paths:
    *  paths:
-   *    /ancestors:
+   *    /user-groups/ancestors:
    *      get:
    *      get:
    *        tags: [UserGroups]
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getAncestorUserGroups
    *        operationId: getAncestorUserGroups
-   *        summary: /ancestors
+   *        summary: /user-groups/ancestors
    *        description: Get ancestor user groups.
    *        description: Get ancestor user groups.
    *        parameters:
    *        parameters:
    *          - name: groupId
    *          - name: groupId
@@ -151,7 +186,7 @@ module.exports = (crowi) => {
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
    *                  properties:
    *                  properties:
-   *                    userGroups:
+   *                    ancestorUserGroups:
    *                      type: array
    *                      type: array
    *                      items:
    *                      items:
    *                        type: object
    *                        type: object
@@ -175,6 +210,50 @@ module.exports = (crowi) => {
       }
       }
     });
     });
 
 
+  /**
+   * @swagger
+   *    paths:
+   *      /user-groups/children:
+   *        get:
+   *          tags: [UserGroups]
+   *          security:
+   *            - cookieAuth: []
+   *          operationId: getUserGroupChildren
+   *          summary: /user-groups/children
+   *          description: Get child user groups
+   *          parameters:
+   *            - name: parentIds
+   *              in: query
+   *              required: false
+   *              description: IDs of parent user groups
+   *              schema:
+   *                type: array
+   *                items:
+   *                  type: string
+   *            - name: includeGrandChildren
+   *              in: query
+   *              required: false
+   *              description: Whether to include grandchild user groups
+   *              schema:
+   *                type: boolean
+   *          responses:
+   *            200:
+   *              description: Child user groups are fetched
+   *              content:
+   *                application/json:
+   *                  schema:
+   *                    properties:
+   *                      childUserGroups:
+   *                        type: array
+   *                        items:
+   *                          type: object
+   *                        description: Child user group objects
+   *                      grandChildUserGroups:
+   *                        type: array
+   *                        items:
+   *                          type: object
+   *                        description: Grandchild user group objects
+   */
   router.get('/children',
   router.get('/children',
     accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     validator.listChildren,
     validator.listChildren,
@@ -203,6 +282,8 @@ module.exports = (crowi) => {
    *    /user-groups:
    *    /user-groups:
    *      post:
    *      post:
    *        tags: [UserGroups]
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: createUserGroup
    *        operationId: createUserGroup
    *        summary: /user-groups
    *        summary: /user-groups
    *        description: Adds userGroup
    *        description: Adds userGroup
@@ -215,6 +296,12 @@ module.exports = (crowi) => {
    *                  name:
    *                  name:
    *                    type: string
    *                    type: string
    *                    description: name of the userGroup trying to be added
    *                    description: name of the userGroup trying to be added
+   *                  description:
+   *                    type: string
+   *                    description: description of the userGroup trying to be added
+   *                  parentId:
+   *                    type: string
+   *                    description: parentId of the userGroup trying to be added
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: userGroup is added
    *            description: userGroup is added
@@ -256,6 +343,8 @@ module.exports = (crowi) => {
    *    /selectable-parent-groups:
    *    /selectable-parent-groups:
    *      get:
    *      get:
    *        tags: [UserGroups]
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getSelectableParentGroups
    *        operationId: getSelectableParentGroups
    *        summary: /selectable-parent-groups
    *        summary: /selectable-parent-groups
    *        description: Get selectable parent UserGroups
    *        description: Get selectable parent UserGroups
@@ -273,7 +362,7 @@ module.exports = (crowi) => {
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
    *                  properties:
    *                  properties:
-   *                    userGroups:
+   *                    selectableParentGroups:
    *                      type: array
    *                      type: array
    *                      items:
    *                      items:
    *                        type: object
    *                        type: object
@@ -308,6 +397,8 @@ module.exports = (crowi) => {
    *    /selectable-child-groups:
    *    /selectable-child-groups:
    *      get:
    *      get:
    *        tags: [UserGroups]
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getSelectableChildGroups
    *        operationId: getSelectableChildGroups
    *        summary: /selectable-child-groups
    *        summary: /selectable-child-groups
    *        description: Get selectable child UserGroups
    *        description: Get selectable child UserGroups
@@ -325,7 +416,7 @@ module.exports = (crowi) => {
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
    *                  properties:
    *                  properties:
-   *                    userGroups:
+   *                    selectableChildGroups:
    *                      type: array
    *                      type: array
    *                      items:
    *                      items:
    *                        type: object
    *                        type: object
@@ -363,6 +454,8 @@ module.exports = (crowi) => {
    *    /user-groups/{id}:
    *    /user-groups/{id}:
    *      get:
    *      get:
    *        tags: [UserGroups]
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getUserGroupFromGroupId
    *        operationId: getUserGroupFromGroupId
    *        summary: /user-groups/{id}
    *        summary: /user-groups/{id}
    *        description: Get UserGroup from Group ID
    *        description: Get UserGroup from Group ID
@@ -408,6 +501,8 @@ module.exports = (crowi) => {
    *    /user-groups/{id}:
    *    /user-groups/{id}:
    *      delete:
    *      delete:
    *        tags: [UserGroups]
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: deleteUserGroup
    *        operationId: deleteUserGroup
    *        summary: /user-groups/{id}
    *        summary: /user-groups/{id}
    *        description: Deletes userGroup
    *        description: Deletes userGroup
@@ -428,6 +523,11 @@ module.exports = (crowi) => {
    *            description: userGroup id that will be transferred to
    *            description: userGroup id that will be transferred to
    *            schema:
    *            schema:
    *              type: string
    *              type: string
+   *          - name: transferToUserGroupType
+   *            in: query
+   *            description: userGroup type that will be transferred to
+   *            schema:
+   *              type: string
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: userGroup is removed
    *            description: userGroup is removed
@@ -475,6 +575,8 @@ module.exports = (crowi) => {
    *    /user-groups/{id}:
    *    /user-groups/{id}:
    *      put:
    *      put:
    *        tags: [UserGroups]
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: updateUserGroups
    *        operationId: updateUserGroups
    *        summary: /user-groups/{id}
    *        summary: /user-groups/{id}
    *        description: Update userGroup
    *        description: Update userGroup
@@ -485,6 +587,24 @@ module.exports = (crowi) => {
    *            description: id of userGroup
    *            description: id of userGroup
    *            schema:
    *            schema:
    *              type: string
    *              type: string
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  name:
+   *                    type: string
+   *                    description: name of the userGroup trying to be updated
+   *                  description:
+   *                    type: string
+   *                    description: description of the userGroup trying to be updated
+   *                  parentId:
+   *                    type: string
+   *                    description: parentId of the userGroup trying to be updated
+   *                  forceUpdateParents:
+   *                    type: boolean
+   *                    description: whether to update parent groups
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: userGroup is updated
    *            description: userGroup is updated
@@ -528,6 +648,8 @@ module.exports = (crowi) => {
    *    /user-groups/{id}/users:
    *    /user-groups/{id}/users:
    *      get:
    *      get:
    *        tags: [UserGroups]
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getUsersUserGroups
    *        operationId: getUsersUserGroups
    *        summary: /user-groups/{id}/users
    *        summary: /user-groups/{id}/users
    *        description: Get users related to the userGroup
    *        description: Get users related to the userGroup
@@ -548,7 +670,7 @@ module.exports = (crowi) => {
    *                    users:
    *                    users:
    *                      type: array
    *                      type: array
    *                      items:
    *                      items:
-   *                        type: object
+   *                        $ref: '#/components/schemas/User'
    *                      description: user objects
    *                      description: user objects
    */
    */
   router.get('/:id/users',
   router.get('/:id/users',
@@ -581,6 +703,8 @@ module.exports = (crowi) => {
    *    /user-groups/{id}/unrelated-users:
    *    /user-groups/{id}/unrelated-users:
    *      get:
    *      get:
    *        tags: [UserGroups]
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getUnrelatedUsersUserGroups
    *        operationId: getUnrelatedUsersUserGroups
    *        summary: /user-groups/{id}/unrelated-users
    *        summary: /user-groups/{id}/unrelated-users
    *        description: Get users unrelated to the userGroup
    *        description: Get users unrelated to the userGroup
@@ -591,6 +715,26 @@ module.exports = (crowi) => {
    *            description: id of userGroup
    *            description: id of userGroup
    *            schema:
    *            schema:
    *              type: string
    *              type: string
+   *          - name: searchWord
+   *            in: query
+   *            description: search word
+   *            schema:
+   *              type: string
+   *          - name: searchType
+   *            in: query
+   *            description: search type
+   *            schema:
+   *              type: string
+   *          - name: isAlsoNameSearched
+   *            in: query
+   *            description: whether name is also searched
+   *            schema:
+   *              type: boolean
+   *          - name: isAlsoMailSearched
+   *            in: query
+   *            description: whether mail is also searched
+   *            schema:
+   *              type: boolean
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: users are fetched
    *            description: users are fetched
@@ -601,7 +745,7 @@ module.exports = (crowi) => {
    *                    users:
    *                    users:
    *                      type: array
    *                      type: array
    *                      items:
    *                      items:
-   *                        type: object
+   *                        $ref: '#/components/schemas/User'
    *                      description: user objects
    *                      description: user objects
    */
    */
   router.get('/:id/unrelated-users',
   router.get('/:id/unrelated-users',
@@ -641,11 +785,13 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *  paths:
    *  paths:
-   *    /user-groups/{id}/users:
+   *    /user-groups/{id}/users/{username}:
    *      post:
    *      post:
    *        tags: [UserGroups]
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: addUserUserGroups
    *        operationId: addUserUserGroups
-   *        summary: /user-groups/{id}/users
+   *        summary: /user-groups/{id}/users/{username}
    *        description: Add a user to the userGroup
    *        description: Add a user to the userGroup
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
@@ -654,6 +800,12 @@ module.exports = (crowi) => {
    *            description: id of userGroup
    *            description: id of userGroup
    *            schema:
    *            schema:
    *              type: string
    *              type: string
+   *          - name: username
+   *            in: path
+   *            required: true
+   *            description: username of the user
+   *            schema:
+   *              type: string
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: a user is added
    *            description: a user is added
@@ -663,14 +815,11 @@ module.exports = (crowi) => {
    *                  type: object
    *                  type: object
    *                  properties:
    *                  properties:
    *                    user:
    *                    user:
-   *                      type: object
+   *                      $ref: '#/components/schemas/User'
    *                      description: the user added to the group
    *                      description: the user added to the group
-   *                    userGroup:
-   *                      type: object
-   *                      description: the group to which a user was added
-   *                    userGroupRelation:
-   *                      type: object
-   *                      description: the associative entity between user and userGroup
+   *                    createdRelationCount:
+   *                      type: number
+   *                      description: the number of relations created
    */
    */
   router.post('/:id/users/:username',
   router.post('/:id/users/:username',
     accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
@@ -711,11 +860,13 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *  paths:
    *  paths:
-   *    /user-groups/{id}/users:
+   *    /user-groups/{id}/users/{username}:
    *      delete:
    *      delete:
    *        tags: [UserGroups]
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: deleteUsersUserGroups
    *        operationId: deleteUsersUserGroups
-   *        summary: /user-groups/{id}/users
+   *        summary: /user-groups/{id}/users/{username}
    *        description: remove a user from the userGroup
    *        description: remove a user from the userGroup
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
@@ -724,6 +875,12 @@ module.exports = (crowi) => {
    *            description: id of userGroup
    *            description: id of userGroup
    *            schema:
    *            schema:
    *              type: string
    *              type: string
+   *          - name: username
+   *            in: path
+   *            required: true
+   *            description: username of the user
+   *            schema:
+   *              type: string
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: a user was removed
    *            description: a user was removed
@@ -734,13 +891,11 @@ module.exports = (crowi) => {
    *                  properties:
    *                  properties:
    *                    user:
    *                    user:
    *                      type: object
    *                      type: object
+   *                      $ref: '#/components/schemas/User'
    *                      description: the user removed from the group
    *                      description: the user removed from the group
-   *                    userGroup:
-   *                      type: object
-   *                      description: the group from which a user was removed
-   *                    userGroupRelation:
-   *                      type: object
-   *                      description: the associative entity between user and userGroup
+   *                    deletedGroupsCount:
+   *                      type: number
+   *                      description: the number of groups from which the user was removed
    */
    */
   router.delete('/:id/users/:username',
   router.delete('/:id/users/:username',
     accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
@@ -769,6 +924,8 @@ module.exports = (crowi) => {
    *    /user-groups/{id}/user-group-relations:
    *    /user-groups/{id}/user-group-relations:
    *      get:
    *      get:
    *        tags: [UserGroups]
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getUserGroupRelationsUserGroups
    *        operationId: getUserGroupRelationsUserGroups
    *        summary: /user-groups/{id}/user-group-relations
    *        summary: /user-groups/{id}/user-group-relations
    *        description: Get the user group relations for the userGroup
    *        description: Get the user group relations for the userGroup
@@ -818,6 +975,8 @@ module.exports = (crowi) => {
    *    /user-groups/{id}/pages:
    *    /user-groups/{id}/pages:
    *      get:
    *      get:
    *        tags: [UserGroups]
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getPagesUserGroups
    *        operationId: getPagesUserGroups
    *        summary: /user-groups/{id}/pages
    *        summary: /user-groups/{id}/pages
    *        description: Get closed pages for the userGroup
    *        description: Get closed pages for the userGroup

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