فهرست منبع

Merge branch 'master' into feat/enhanced-access-token

reiji-h 1 سال پیش
والد
کامیت
48a2ccbbe9
100فایلهای تغییر یافته به همراه4011 افزوده شده و 402 حذف شده
  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. 19 3
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  42. 2 2
      apps/app/src/client/components/Navbar/PageEditorModeManager.tsx
  43. 1 1
      apps/app/src/client/components/SearchPage/SearchResultContent.tsx
  44. 6 1
      apps/app/src/client/services/AdminAppContainer.js
  45. 3 0
      apps/app/src/components/Layout/BasicLayout.tsx
  46. 498 0
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  47. 3 3
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx
  48. 1 1
      apps/app/src/features/openai/interfaces/ai-assistant.ts
  49. 25 9
      apps/app/src/features/openai/server/models/ai-assistant.ts
  50. 1 1
      apps/app/src/features/openai/server/routes/ai-assistant.ts
  51. 2 4
      apps/app/src/features/openai/server/routes/update-ai-assistant.ts
  52. 16 11
      apps/app/src/features/openai/server/services/openai.ts
  53. 119 0
      apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx
  54. 27 0
      apps/app/src/features/page-bulk-export/client/stores/modal.tsx
  55. 49 0
      apps/app/src/features/page-bulk-export/interfaces/page-bulk-export.ts
  56. 29 0
      apps/app/src/features/page-bulk-export/server/models/page-bulk-export-job.ts
  57. 19 0
      apps/app/src/features/page-bulk-export/server/models/page-bulk-export-page-snapshot.ts
  58. 58 0
      apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts
  59. 42 0
      apps/app/src/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron.ts
  60. 180 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.integ.ts
  61. 118 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.ts
  62. 15 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/errors.ts
  63. 285 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts
  64. 62 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts
  65. 70 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts
  66. 103 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/create-page-snapshots-async.ts
  67. 112 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/export-pages-to-fs-async.ts
  68. 78 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export.ts
  69. 28 28
      apps/app/src/features/questionnaire/server/service/questionnaire-cron.integ.ts
  70. 16 42
      apps/app/src/features/questionnaire/server/service/questionnaire-cron.ts
  71. 14 0
      apps/app/src/interfaces/activity.ts
  72. 28 0
      apps/app/src/interfaces/file-uploader.ts
  73. 5 0
      apps/app/src/interfaces/res/admin/app-settings.ts
  74. 12 0
      apps/app/src/migrations/19700101000000-foremost-1000-20241123211930-remove-index-for-ns-from-configs.js
  75. 25 0
      apps/app/src/models/serializers/in-app-notification-snapshot/page-bulk-export-job.ts
  76. 9 1
      apps/app/src/pages/[[...path]].page.tsx
  77. 17 14
      apps/app/src/server/crowi/index.js
  78. 7 0
      apps/app/src/server/interfaces/attachment.ts
  79. 2 1
      apps/app/src/server/models/activity.ts
  80. 6 2
      apps/app/src/server/models/attachment.ts
  81. 8 4
      apps/app/src/server/models/subscription.ts
  82. 0 13
      apps/app/src/server/models/vo/collection-progress.js
  83. 19 0
      apps/app/src/server/models/vo/collection-progress.ts
  84. 12 7
      apps/app/src/server/models/vo/collection-progressing-status.ts
  85. 3 5
      apps/app/src/server/routes/admin.js
  86. 38 1
      apps/app/src/server/routes/apiv3/app-settings.js
  87. 2 1
      apps/app/src/server/routes/apiv3/export.js
  88. 5 4
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  89. 1 0
      apps/app/src/server/routes/apiv3/index.js
  90. 5 1
      apps/app/src/server/routes/apiv3/page/index.ts
  91. 89 19
      apps/app/src/server/routes/apiv3/personal-setting/index.js
  92. 171 9
      apps/app/src/server/routes/apiv3/security-settings/index.js
  93. 181 11
      apps/app/src/server/routes/apiv3/slack-integration-settings.js
  94. 183 24
      apps/app/src/server/routes/apiv3/user-group.js
  95. 161 24
      apps/app/src/server/routes/apiv3/users.js
  96. 1 1
      apps/app/src/server/service/attachment.js
  97. 47 0
      apps/app/src/server/service/config-manager/config-definition.ts
  98. 65 0
      apps/app/src/server/service/cron.ts
  99. 58 38
      apps/app/src/server/service/export.ts
  100. 1 1
      apps/app/src/server/service/external-account.ts

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

@@ -2,8 +2,8 @@
 // README at: https://github.com/devcontainers/templates/tree/main/src/ubuntu
 {
   "name": "GROWI-Dev",
-  "dockerComposeFile": "compose.yml",
-  "service": "node",
+  "dockerComposeFile": ["../compose.yml", "../compose.extend.yml"],
+  "service": "app",
   "workspaceFolder": "/workspace/growi",
 
   "features": {
@@ -15,8 +15,9 @@
   // 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/postCreateCommand.sh",
+  "postCreateCommand": "/bin/bash ./.devcontainer/app/postCreateCommand.sh",
 
   // Configure tool-specific properties.
   "customizations": {
@@ -37,7 +38,10 @@
         "vitest.explorer",
         "ms-playwright.playwright"
       ],
-    }
+      "settings": {
+        "terminal.integrated.defaultProfile.linux": "bash"
+      }
+    },
   },
 
   // 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
 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
 SHELL=bash pnpm setup
 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:
-  node:
+  app:
     image: mcr.microsoft.com/devcontainers/base:ubuntu
     volumes:
       - ..:/workspace/growi:delegated
@@ -8,6 +8,7 @@ services:
       - buildcache_app:/workspace/growi/apps/app/.next
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
       - ../../share:/workspace/share:delegated
+      - page_bulk_export_tmp:/tmp/page-bulk-export
     tty: true
     networks:
     - default
@@ -48,6 +49,7 @@ volumes:
   pnpm-store:
   node_modules:
   buildcache_app:
+  page_bulk_export_tmp:
 
 networks:
   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:
       - name: Dependabot metadata
         id: dependabot-metadata
-        uses: dependabot/fetch-metadata@v1
+        uses: dependabot/fetch-metadata@v2
         with:
           github-token: '${{ secrets.GITHUB_TOKEN }}'
       - name: Approve a PR

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

@@ -25,7 +25,7 @@ jobs:
         && !startsWith( github.head_ref, 'mergify/merge-queue/' ))
 
     steps:
-      - uses: release-drafter/release-drafter@v5
+      - uses: release-drafter/release-drafter@v6
         with:
           disable-releaser: true
         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
         id: package-json
 
-      - uses: release-drafter/release-drafter@v5
+      - uses: release-drafter/release-drafter@v6
         id: release-drafter
         with:
           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
 
     - name: Build and push
-      uses: docker/build-push-action@v4
+      uses: docker/build-push-action@v6
       with:
         context: .
         file: ./apps/slackbot-proxy/docker/Dockerfile
@@ -62,11 +62,6 @@ jobs:
         cache-to: type=gha,mode=max
         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
       uses: anothrNick/github-tag-action@v1
       env:

+ 2 - 0
.gitignore

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

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

@@ -65,6 +65,7 @@ module.exports = {
         'Home',
         'AdminHome',
         'AppSettings',
+        'ExternalUserGroups',
         'SecuritySetting',
         'MarkDownSetting',
         'CustomizeSetting',
@@ -72,6 +73,7 @@ module.exports = {
         'Export',
         'MongoDB',
         'NotificationSetting',
+        'QuestionnaireSetting',
         'SlackIntegrationSettings',
         'SlackIntegrationSettings (with 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 \
   -o "${OUT}" \
   -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/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
   ln -s /data/uploads ./public/uploads
 fi
-
 chown -R node:node /data/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 "$@"

+ 5 - 0
apps/app/package.json

@@ -82,6 +82,7 @@
     "@growi/remark-growi-directive": "workspace:^",
     "@growi/remark-lsx": "workspace:^",
     "@growi/slack": "workspace:^",
+    "@growi/pdf-converter-client": "workspace:^",
     "@keycloak/keycloak-admin-client": "^18.0.0",
     "@opentelemetry/api": "^1.9.0",
     "@opentelemetry/auto-instrumentations-node": "^0.55.1",
@@ -94,12 +95,14 @@
     "@opentelemetry/sdk-trace-node": "^1.28.0",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
+    "@types/async": "^3.2.24",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
     "axios": "^0.24.0",
     "axios-retry": "^3.2.4",
+    "babel-plugin-superjson-next": "^0.4.2",
     "body-parser": "^1.20.3",
     "browser-bunyan": "^1.8.0",
     "bson-objectid": "^2.0.4",
@@ -214,6 +217,7 @@
     "remark-directive": "^3.0.0",
     "remark-frontmatter": "^5.0.0",
     "remark-gfm": "^4.0.0",
+    "remark-html": "^16.0.1",
     "remark-math": "^6.0.0",
     "remark-parse": "^11.0.0",
     "remark-rehype": "^11.1.1",
@@ -265,6 +269,7 @@
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/react": "^16.0.1",
     "@testing-library/user-event": "^14.5.2",
+    "@types/archiver": "^6.0.2",
     "@types/bunyan": "^1.8.11",
     "@types/express": "^4.17.21",
     "@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_inside_the_group": "Only inside the group",
   "optional": "Optional",
+  "days": "days",
   "security_settings": {
     "security_settings": "Security Settings",
     "scope_of_page_disclosure": "Scope of page disclosure",
@@ -359,6 +360,11 @@
     "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.",
     "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",
     "mail_settings": "E-mail Settings",
     "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_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.",
-    "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)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
@@ -1062,7 +1068,8 @@
     "ADMIN_USER_GROUP_ADD_USER": "Add User to User Group",
     "ADMIN_SEARCH_CONNECTION": "Attempting to reconnect to Elasticsearch",
     "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": {
     "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": "Create an your account with the invited email address"
   },
-  "export_bulk": {
+  "page_export": {
     "failed_to_export": "Failed to export",
     "failed_to_count_pages": "Failed to count pages",
     "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": {
     "successfully_connected": "Successfully Connected!",

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

@@ -10,6 +10,7 @@
   "only_me": "Seulement moi",
   "only_inside_the_group": "Utilisateurs du groupe",
   "optional": "Optionnel",
+  "days": "jours",
   "security_settings": {
     "security_settings": "Sécurité",
     "scope_of_page_disclosure": "Confidentialité de la page",
@@ -359,6 +360,11 @@
     "file_uploading": "Téléversement 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.",
+    "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",
     "mail_settings": "SMTP",
     "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_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.",
-    "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)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
@@ -1061,7 +1067,8 @@
     "ADMIN_USER_GROUP_ADD_USER": "Ajouter l'utilisateur au groupe",
     "ADMIN_SEARCH_CONNECTION": "Essai de reconnexion 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": {
     "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": "Créer un compte avec votre adresse courriel invitée"
   },
-  "export_bulk": {
+  "page_export": {
     "failed_to_export": "Échec de l'export",
     "failed_to_count_pages": "Échec du compte des pages",
     "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": {
     "successfully_connected": "Connecté!",

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

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

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

@@ -810,11 +810,26 @@
     "discription_heading": "アカウント作成",
     "discription": "招待を受け取ったメールアドレスでアカウントを作成します"
   },
-  "export_bulk": {
+  "page_export": {
     "failed_to_export": "ページのエクスポートに失敗しました",
     "failed_to_count_pages": "ページ数の取得に失敗しました",
     "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": {
     "successfully_connected": "接続に成功しました!",

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

@@ -19,6 +19,7 @@
   "only_me": "只有我",
   "only_inside_the_group": "仅组内",
   "optional": "可选的",
+  "days": "天",
   "security_settings": {
     "security_settings": "安全设置",
     "scope_of_page_disclosure": "页面公开范围",
@@ -368,6 +369,11 @@
     "file_uploading": "文件上传",
     "enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
     "attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
+    "page_bulk_export_settings": "页面批量导出设置",
+    "enable_page_bulk_export": "启用批量导出",
+    "page_bulk_export_explanation": "启用一项功能,允许所有用户一次性导出从页面菜单中选择的所有页面及其下级页面。保留期限过后,导出的数据将自动删除。",
+    "page_bulk_export_warning": "批量页面导出功能可供所有用户使用。为了维护系统资源,请您配合使用最低限度的资源。如果您是管理员,请将此事实告知所有用户。",
+    "page_bulk_export_storage_period": "储存期限",
     "update": "更新",
     "mail_settings": "邮件设置",
     "mailer_is_not_set_up": "邮件设置尚未完成。",
@@ -402,7 +408,7 @@
     "azure_storage_account_name": "Storage Account 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> .",
-    "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.",
     "test_connection": "测试邮件服务器连接",
     "change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
@@ -1071,7 +1077,8 @@
     "ADMIN_USER_GROUP_ADD_USER": "添加用户到用户组",
     "ADMIN_SEARCH_CONNECTION": "重试Elasticsearch连接",
     "ADMIN_SEARCH_INDICES_NORMALIZE": "试图重新连接Elasticsearch",
-    "ADMIN_SEARCH_INDICES_REBUILD": "重建 Elasticsearch 索引"
+    "ADMIN_SEARCH_INDICES_REBUILD": "重建 Elasticsearch 索引",
+    "ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE": "更新页面批量导出设置"
   },
   "g2g": {
     "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": "用被邀请的电子邮件地址创建一个你的账户"
   },
-  "export_bulk": {
+  "page_export": {
     "failed_to_export": "导出失败",
     "failed_to_count_pages": "页面计数失败",
     "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": {
     "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 MailSetting from './MailSetting';
 import { MaintenanceMode } from './MaintenanceMode';
+import PageBulkExportSettings from './PageBulkExportSettings';
 import QuestionnaireSettings from './QuestionnaireSettings';
 import SiteUrlSetting from './SiteUrlSetting';
 import V5PageMigration from './V5PageMigration';
@@ -108,6 +109,16 @@ const AppSettingsPageContents = (props: Props) => {
         </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="col-lg-12">
           <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 { toastSuccess, toastError } from '~/client/util/toastr';
+import { FileUploadType } from '~/interfaces/file-uploader';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
@@ -16,9 +17,6 @@ import type { AzureSettingMoleculeProps } from './AzureSetting';
 import { GcsSettingMolecule } from './GcsSetting';
 import type { GcsSettingMoleculeProps } from './GcsSetting';
 
-
-const fileUploadTypes = ['aws', 'gcs', 'azure', 'gridfs', 'local'] as const;
-
 type FileUploadSettingMoleculeProps = {
   fileUploadType: string
   isFixedFileUploadByEnvVar: boolean
@@ -45,7 +43,7 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
         </label>
 
         <div className="col-md-6 py-2">
-          {fileUploadTypes.map((type) => {
+          {Object.values(FileUploadType).map((type) => {
             return (
               <div key={type} className="form-check form-check-inline">
                 <input
@@ -67,7 +65,13 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
             <span className="material-symbols-outlined">help</span>
             <b>FIXED</b><br />
             {/* 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>
         )}
       </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 && (
         <>
-          <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 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>
 

+ 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 { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 
-import { useModelNotification } from './PageNotification';
+import { useModelNotification } from './ModelNotification';
 
 interface Props {
   notification: IInAppNotification & HasObjectId
@@ -24,9 +24,11 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
 
   const Notification = modelNotificationUtils?.Notification;
   const publishOpen = modelNotificationUtils?.publishOpen;
+  const clickLink = modelNotificationUtils?.clickLink;
+  const isDisabled = modelNotificationUtils?.isDisabled;
   const { mutate: mutateNotificationCount } = useSWRxInAppNotificationStatus();
 
-  if (Notification == null || publishOpen == null) {
+  if (Notification == null) {
     return <></>;
   }
 
@@ -38,7 +40,9 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       mutateNotificationCount();
     }
 
-    publishOpen();
+    if (isDisabled) return;
+
+    publishOpen?.();
   };
 
   const renderActionUserPictures = (): JSX.Element => {
@@ -61,21 +65,26 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
   };
 
   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>
   );
 };

+ 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
   actionIcon: 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 (
     <div className={`${styles['modal-notification']} p-2 overflow-hidden`}>
       <div className="text-truncate page-title">
-        <b>{actionUsers}</b>
-        {actionMsg}
+        {hideActionUsers ? <></> : <b>{actionUsers}</b>}
+        {` ${actionMsg}`}
         <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
       </div>
+      { subMsg }
       <span className="material-symbols-outlined me-2">{actionIcon}</span>
       <FormattedDistanceDate
         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 { useRouter } from 'next/router';
@@ -12,11 +10,7 @@ import * as pageSerializers from '~/models/serializers/in-app-notification-snaps
 import { ModelNotification } from './ModelNotification';
 import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 
-
-export interface ModelNotificationUtils {
-  Notification: FC
-  publishOpen: () => void
-}
+import type { ModelNotificationUtils } from '.';
 
 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 { ModelNotification } from './ModelNotification';
-import { ModelNotificationUtils } from './PageModelNotification';
 import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 
+import type { ModelNotificationUtils } from '.';
+
 
 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';
       actionIcon = 'add_comment';
       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:
       actionMsg = '';
       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;
-};

+ 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 { useTranslation } from 'next-i18next';
 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 { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
 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 { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
 import {
@@ -36,7 +37,7 @@ import {
 } from '~/stores/ui';
 import {
   useCurrentPathname,
-  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsLocalAccountRegistrationEnabled, useIsSharedUser, useShareLinkId,
+  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsBulkExportPagesEnabled, useIsLocalAccountRegistrationEnabled, useIsSharedUser, useShareLinkId,
 } from '~/stores-universal/context';
 import { useEditorMode } from '~/stores-universal/ui';
 
@@ -75,9 +76,11 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isSharedUser } = useIsSharedUser();
+  const { data: isBulkExportPagesEnabled } = useIsBulkExportPagesEnabled();
 
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
+  const { open: openPageBulkExportSelectModal } = usePageBulkExportSelectModal();
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
 
@@ -134,9 +137,22 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         className="grw-page-control-dropdown-item"
       >
         <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>
 
+      {/* 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 />
 
       {/*

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

@@ -1,7 +1,7 @@
 import React, { type ReactNode, useCallback, useMemo } from 'react';
 
 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 { useCreatePage } from '~/client/services/create-page';
@@ -78,7 +78,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
     }
 
     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(
         {
           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"
     >
       <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>
   );
 };

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

@@ -76,6 +76,9 @@ export default class AdminAppContainer extends Container {
       isEnabledPlugins: true,
 
       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,
       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.
@@ -157,7 +163,6 @@ export default class AdminAppContainer extends Container {
       this.setState({ fileUploadType: appSettingsParams.envFileUploadType });
       this.setState({ isFixedFileUploadByEnvVar: true });
     }
-
   }
 
   /**

+ 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 },
 );
 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(
   () => import('~/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal')
     .then(mod => mod.AiAssistantManagementModal), { ssr: false },
@@ -86,6 +88,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
       <HotkeysManager />
 
       <ShortcutsModal />
+      <PageBulkExportSelectModal />
       <GrantedGroupsInheritanceSelectModal />
       <SystemVersion showShortcutsButton />
     </RawLayout>

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

@@ -27,6 +27,21 @@ interface AuthorizedRequest extends Request {
   user?: any
 }
 
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      SyncStatus:
+ *        type: object
+ *        properties:
+ *          isExecutingSync:
+ *            type: boolean
+ *          totalCount:
+ *            type: number
+ *          count:
+ *            type: number
+ */
 module.exports = (crowi: Crowi): Router => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
@@ -79,6 +94,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('/', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { query } = req;
 
@@ -101,6 +164,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', loginRequiredStrictly, adminRequired, validators.ancestorGroup, apiV3FormValidator, async(req, res: ApiV3Response) => {
     const { groupId } = req.query;
 
@@ -116,6 +209,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', loginRequiredStrictly, adminRequired, validators.listChildren, async(req, res) => {
     try {
       const { parentIds, includeGrandChildren = false } = req.query;
@@ -133,6 +266,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', loginRequiredStrictly, adminRequired, validators.detail, async(req, res: ApiV3Response) => {
     const { id } = req.params;
 
@@ -147,6 +308,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', loginRequiredStrictly, adminRequired, validators.delete, apiV3FormValidator, addActivity,
     async(req: AuthorizedRequest, res: ApiV3Response) => {
       const { id: deleteGroupId } = req.params;
@@ -176,6 +383,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', loginRequiredStrictly, adminRequired, validators.update, apiV3FormValidator, addActivity, async(req, res: ApiV3Response) => {
     const { id } = req.params;
     const {
@@ -197,6 +441,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
+   */
   router.get('/:id/external-user-group-relations', loginRequiredStrictly, adminRequired, async(req, res: ApiV3Response) => {
     const { id } = req.params;
 
@@ -214,6 +488,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', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
     const settings = {
       ldapGroupSearchBase: configManager.getConfig('external-user-group:ldap:groupSearchBase'),
@@ -229,6 +538,41 @@ module.exports = (crowi: Crowi): Router => {
     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', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
     const settings = {
       keycloakHost: configManager.getConfig('external-user-group:keycloak:host'),
@@ -244,6 +588,47 @@ module.exports = (crowi: Crowi): Router => {
     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', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const errors = validationResult(req);
     if (!errors.isEmpty()) {
@@ -280,6 +665,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', loginRequiredStrictly, adminRequired, validators.keycloakSyncSettings,
     async(req: AuthorizedRequest, res: ApiV3Response) => {
       const errors = validationResult(req);
@@ -312,6 +738,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', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     if (isExecutingSync()) {
       return res.apiv3Err(
@@ -341,6 +785,24 @@ module.exports = (crowi: Crowi): Router => {
     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', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     if (isExecutingSync()) {
       return res.apiv3Err(
@@ -386,11 +848,47 @@ module.exports = (crowi: Crowi): Router => {
     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', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
     const syncStatus = crowi.ldapUserGroupSyncService?.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', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
     const syncStatus = crowi.keycloakUserGroupSyncService?.syncStatus;
     return res.apiv3({ ...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 { 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 type { IPagePathWithDescendantCount, IPageForItem } from '~/interfaces/page';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
@@ -131,7 +132,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
         ? convertToGrantedGroups(selectedUserGroupsForAccessScope)
         : undefined;
 
-      const reqBody = {
+      const reqBody: UpsertAiAssistantData = {
         name,
         description,
         additionalInstruction: instruction,
@@ -140,7 +141,6 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
         accessScope: selectedAccessScope,
         grantedGroupsForShareScope,
         grantedGroupsForAccessScope,
-        isDefault: shouldEdit ? aiAssistant.isDefault : false,
       };
 
       if (shouldEdit) {
@@ -159,7 +159,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
       logger.error(err);
     }
   // 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 UpsertAiAssistantData = Omit<AiAssistant, 'owner' | 'vectorStore'>
+export type UpsertAiAssistantData = Omit<AiAssistant, 'owner' | 'vectorStore' | 'isDefault'>
 
 export type AccessibleAiAssistants = {
   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 createError from 'http-errors';
 import { type Model, type Document, Schema } from 'mongoose';
 
 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> {
-  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;
 };
 

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

@@ -43,7 +43,7 @@ export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
           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 });
       }

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

@@ -50,14 +50,12 @@ export const updateAiAssistantsFactory: UpdateAiAssistantsFactory = (crowi) => {
       }
 
       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) {
           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 });
       }

+ 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 { OpenaiServiceTypes } from '../../interfaces/ai';
+import type { UpsertAiAssistantData } from '../../interfaces/ai-assistant';
 import {
   type AccessibleAiAssistants, type AiAssistant, AiAssistantAccessScope, AiAssistantShareScope,
 } from '../../interfaces/ai-assistant';
@@ -79,8 +80,8 @@ export interface IOpenaiService {
   deleteVectorStoreFilesByPageIds(pageIds: Types.ObjectId[]): Promise<void>;
   deleteObsoleteVectorStoreFile(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
   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>
   isLearnablePageLimitExceeded(user: IUserHasId, pagePathPatterns: string[]): Promise<boolean>;
 }
@@ -758,9 +759,9 @@ class OpenaiService implements IOpenaiService {
     return false;
   }
 
-  async createAiAssistant(data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument> {
+  async createAiAssistant(data: UpsertAiAssistantData, user: IUserHasId): Promise<AiAssistantDocument> {
     await this.validateGrantedUserGroupsForAiAssistant(
-      data.owner,
+      user,
       data.shareScope,
       data.accessScope,
       data.grantedGroupsForShareScope,
@@ -768,7 +769,7 @@ class OpenaiService implements IOpenaiService {
     );
 
     const conditions = await this.createConditionForCreateVectorStoreFile(
-      data.owner,
+      user,
       data.accessScope,
       data.grantedGroupsForAccessScope,
       data.pagePathPatterns,
@@ -776,7 +777,7 @@ class OpenaiService implements IOpenaiService {
 
     const vectorStoreRelation = await this.createVectorStore(data.name);
     const aiAssistant = await AiAssistantModel.create({
-      ...data, vectorStore: vectorStoreRelation,
+      ...data, owner: user, vectorStore: vectorStoreRelation,
     });
 
     // VectorStore creation process does not await
@@ -785,14 +786,14 @@ class OpenaiService implements IOpenaiService {
     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) {
       throw createError(404, 'AiAssistant document does not exist');
     }
 
     await this.validateGrantedUserGroupsForAiAssistant(
-      data.owner,
+      user,
       data.shareScope,
       data.accessScope,
       data.grantedGroupsForShareScope,
@@ -810,7 +811,7 @@ class OpenaiService implements IOpenaiService {
     let newVectorStoreRelation: VectorStoreDocument | undefined;
     if (shouldRebuildVectorStore) {
       const conditions = await this.createConditionForCreateVectorStoreFile(
-        data.owner,
+        user,
         data.accessScope,
         data.grantedGroupsForAccessScope,
         data.pagePathPatterns,
@@ -834,7 +835,11 @@ class OpenaiService implements IOpenaiService {
     };
 
     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;
   }

+ 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 mongoose from 'mongoose';
 
+import { configManager } from '~/server/service/config-manager';
+
+import { AttachmentMethodType } from '../../../../interfaces/attachment';
 import type {
   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', () => {
-  let crowi;
-
   const mockResponse = {
     data: {
       questionnaireOrders: [
@@ -141,14 +143,12 @@ describe('QuestionnaireCronService', () => {
   };
 
   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({
       name: 'Example for Questionnaire Service Test',
       username: 'questionnaire cron test user',
       email: 'questionnaireCronTestUser@example.com',
-      password: 'usertestpass',
       createdAt: '2020-01-01',
     });
   });
@@ -419,19 +419,19 @@ describe('QuestionnaireCronService', () => {
       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(() => {
-    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() => {
     // 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()
       .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 type Crowi from '~/server/crowi';
 import { configManager } from '~/server/service/config-manager';
-import loggerFactory from '~/utils/logger';
+import CronService from '~/server/service/cron';
 import { getRandomIntInRange } from '~/utils/rand';
 
 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 { convertToLegacyFormat } from '../util/convert-to-legacy-format';
 
-const logger = loggerFactory('growi:service:questionnaire-cron');
-
 const axios = require('axios').default;
-const nodeCron = require('node-cron');
 
 axiosRetry(axios, { retries: 3 });
 
 /**
- * manage cronjob which
+ * Manages cronjob which
  *  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)
  *  3. changes QuestionnaireAnswerStatuses which are 'skipped' to 'not_answered'
  *  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));
 
-  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 isAppSiteUrlHashed = configManager.getConfig('questionnaire:isAppSiteUrlHashed');
 
@@ -111,22 +91,16 @@ class QuestionnaireCronService {
     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_USER = 'User';
 const MODEL_COMMENT = 'Comment';
+const MODEL_PAGE_BULK_EXPORT_JOB = 'PageBulkExportJob';
 
 // Action
 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_UNSUBSCRIBE = 'PAGE_UNSUBSCRIBE';
 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_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN = 'IN_APP_NOTIFICATION_ALL_STATUSES_OPEN';
 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_FILE_UPLOAD_CONFIG_UPDATE = 'ADMIN_FILE_UPLOAD_CONFIG_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_DISABLED = 'ADMIN_MAINTENANCEMODE_DISABLED';
 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 = {
   MODEL_PAGE,
   MODEL_USER,
+  MODEL_PAGE_BULK_EXPORT_JOB,
 } as const;
 
 export const SupportedEventModel = {
@@ -256,6 +262,7 @@ export const SupportedAction = {
   ACTION_ADMIN_MAIL_TEST_SUBMIT,
   ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
   ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE,
+  ACTION_ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE,
   ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
   ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
   ACTION_ADMIN_SECURITY_SETTINGS_UPDATE,
@@ -342,6 +349,9 @@ export const SupportedAction = {
   ACTION_ADMIN_SEARCH_CONNECTION,
   ACTION_ADMIN_SEARCH_INDICES_NORMALIZE,
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
+  ACTION_PAGE_BULK_EXPORT_COMPLETED,
+  ACTION_PAGE_BULK_EXPORT_FAILED,
+  ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED,
 } as const;
 
 // Action required for notification
@@ -360,6 +370,9 @@ export const EssentialActionGroup = {
   ACTION_PAGE_RECURSIVELY_REVERT,
   ACTION_COMMENT_CREATE,
   ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
+  ACTION_PAGE_BULK_EXPORT_COMPLETED,
+  ACTION_PAGE_BULK_EXPORT_FAILED,
+  ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED,
 } as const;
 
 export const ActionGroupSize = {
@@ -445,6 +458,7 @@ export const LargeActionGroup = {
   ACTION_ADMIN_MAIL_TEST_SUBMIT,
   ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
   ACTION_ADMIN_QUESTIONNAIRE_SETTINGS_UPDATE,
+  ACTION_ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE,
   ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
   ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
   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,
 
   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');
     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
     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,
   useIsSlackConfigured, useRendererConfig, useGrowiCloudUri,
   useIsAllReplyShown, useShowPageSideAuthors, useIsContainerFluid, useIsNotCreatable,
-  useIsUploadAllFileAllowed, useIsUploadEnabled,
+  useIsUploadAllFileAllowed, useIsUploadEnabled, useIsBulkExportPagesEnabled,
   useElasticsearchMaxBodyLengthToIndex,
   useIsLocalAccountRegistrationEnabled,
   useIsRomUserAllowedToComment,
+  useIsPdfBulkExportEnabled,
   useIsAiEnabled, useLimitLearnablePageCountPerAssistant,
 } from '~/stores-universal/context';
 import { useEditingMarkdown } from '~/stores/editor';
@@ -181,6 +182,8 @@ type Props = CommonProps & {
   isContainerFluid: boolean,
   isUploadEnabled: boolean,
   isUploadAllFileAllowed: boolean,
+  isBulkExportPagesEnabled: boolean,
+  isPdfBulkExportEnabled: boolean,
   isEnabledStaleNotification: boolean,
   isEnabledAttachTitleHeader: boolean,
   // isEnabledLinebreaks: boolean,
@@ -246,6 +249,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
   useIsUploadAllFileAllowed(props.isUploadAllFileAllowed);
   useIsUploadEnabled(props.isUploadEnabled);
+  useIsBulkExportPagesEnabled(props.isBulkExportPagesEnabled);
+  useIsPdfBulkExportEnabled(props.isPdfBulkExportEnabled);
 
   useIsLocalAccountRegistrationEnabled(props.isLocalAccountRegistrationEnabled);
   useIsRomUserAllowedToComment(props.isRomUserAllowedToComment);
@@ -592,6 +597,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.disableLinkSharing = configManager.getConfig('security:disableLinkSharing');
   props.isUploadAllFileAllowed = fileUploadService.getFileUploadEnabled();
   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
   && 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 { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
 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 QuestionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
+import questionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
 import { getGrowiVersion } from '~/utils/growi-version';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
@@ -23,9 +28,11 @@ import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
 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 { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
+import GrowiBridgeService from '../service/growi-bridge';
 import { initializeImportService } from '../service/import';
 import { InstallerService } from '../service/installer';
 import { normalizeData } from '../service/normalize-data';
@@ -90,9 +97,6 @@ class Crowi {
   /** @type {QuestionnaireService} */
   questionnaireService;
 
-  /** @type {QuestionnaireCronService} */
-  questionnaireCronService;
-
   /** @type {import('../service/rest-qiita-API')} */
   restQiitaAPIService;
 
@@ -134,7 +138,6 @@ class Crowi {
     this.appService = null;
     this.fileUploadService = null;
     this.growiBridgeService = null;
-    this.exportService = null;
     this.pluginService = null;
     this.searchService = null;
     this.socketIoService = null;
@@ -144,7 +147,6 @@ class Crowi {
     this.activityService = null;
     this.commentService = null;
     this.questionnaireService = null;
-    this.questionnaireCronService = null;
     this.openaiThreadDeletionCronService = null;
     this.openaiVectorStoreFileDeletionCronService = null;
 
@@ -355,8 +357,13 @@ Crowi.prototype.setupSocketIoService = async function() {
 };
 
 Crowi.prototype.setupCron = function() {
-  this.questionnaireCronService = new QuestionnaireCronService(this);
-  this.questionnaireCronService.startCron();
+  questionnaireCronService.startCron();
+
+  instanciatePageBulkExportJobCronService(this);
+  checkPageBulkExportJobInProgressCronService.startCron();
+
+  instanciatePageBulkExportJobCleanUpCronService(this);
+  pageBulkExportJobCleanUpCronService.startCron();
 
   startOpenaiCronIfEnabled();
 };
@@ -701,17 +708,13 @@ Crowi.prototype.setupUserGroupService = async function() {
 };
 
 Crowi.prototype.setUpGrowiBridge = async function() {
-  const GrowiBridgeService = require('../service/growi-bridge');
   if (this.growiBridgeService == null) {
     this.growiBridgeService = new GrowiBridgeService(this);
   }
 };
 
 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() {

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

@@ -2,6 +2,7 @@ export const AttachmentType = {
   BRAND_LOGO: 'BRAND_LOGO',
   WIKI_PAGE: 'WIKI_PAGE',
   PROFILE_IMAGE: 'PROFILE_IMAGE',
+  PAGE_BULK_EXPORT: 'PAGE_BULK_EXPORT',
 } as const;
 
 export type AttachmentType = typeof AttachmentType[keyof typeof AttachmentType];
@@ -29,3 +30,9 @@ export const ResponseMode = {
   DELEGATE: 'delegate',
 } as const;
 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 {
   Types, Document, Model, SortOrder,
 } from 'mongoose';
@@ -21,7 +22,7 @@ const logger = loggerFactory('growi:models:activity');
 
 export interface ActivityDocument extends Document {
   _id: Types.ObjectId
-  user: Types.ObjectId
+  user: Ref<IUser>
   ip: string
   endpoint: string
   targetModel: SupportedTargetModelType

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

@@ -32,7 +32,9 @@ export interface IAttachmentDocument extends IAttachment, Document {
   cashTemporaryUrlByProvideSec: CashTemporaryUrlByProvideSec,
 }
 export interface IAttachmentModel extends Model<IAttachmentDocument> {
-  createWithoutSave
+  createWithoutSave: (
+    pageId, user, originalName: string, fileFormat: string, fileSize: number, attachmentType: AttachmentType,
+  ) => IAttachmentDocument;
 }
 
 const attachmentSchema = new Schema({
@@ -69,7 +71,9 @@ attachmentSchema.set('toObject', { 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
   const Attachment = this;
 

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

@@ -8,7 +8,9 @@ import {
   type Types, type Document, type Model, Schema,
 } 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';
 
@@ -17,7 +19,7 @@ export interface SubscriptionDocument extends ISubscription, Document {}
 
 export interface SubscriptionModel extends Model<SubscriptionDocument> {
   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
   getSubscription(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 });
 };
 
-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 doc = { ...query, status };
   const options = {
@@ -73,7 +77,7 @@ subscriptionSchema.statics.upsertSubscription = function(user, targetModel, targ
 };
 
 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>) {

+ 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 {
 
-  constructor(collections) {
-    this.totalCount = 0;
+  totalCount = 0;
+
+  progressList: CollectionProgress[];
+
+  progressMap: Record<string, CollectionProgress>;
+
+  constructor(collections: string[]) {
     this.progressMap = {};
 
     this.progressList = collections.map((collectionName) => {
-      return new CollectionProgress(collectionName, 0);
+      return new CollectionProgress(collectionName);
     });
 
     // collection name to instance mapping
@@ -16,14 +21,14 @@ class CollectionProgressingStatus {
     });
   }
 
-  recalculateTotalCount() {
+  recalculateTotalCount(): void {
     this.progressList.forEach((p) => {
       this.progressMap[p.collectionName] = p;
       this.totalCount += p.totalCount;
     });
   }
 
-  get currentCount() {
+  get currentCount(): number {
     return this.progressList.reduce(
       (acc, crr) => acc + crr.currentCount,
       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 loggerFactory from '~/utils/logger';
 
+import { configManager } from '../service/config-manager';
+import { exportService } from '../service/export';
+
 const logger = loggerFactory('growi:routes:admin');
 
 /* eslint-disable no-use-before-define */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = function(crowi, app) {
-  const {
-    configManager,
-    exportService,
-  } = crowi;
-
   const ApiResponse = require('../util/apiResponse');
   const importer = require('../util/importer')(crowi);
 

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

@@ -403,6 +403,10 @@ module.exports = (crowi) => {
       body('isQuestionnaireEnabled').isBoolean(),
       body('isAppSiteUrlHashed').isBoolean(),
     ],
+    pageBulkExportSettings: [
+      body('isBulkExportPagesEnabled').isBoolean(),
+      body('bulkExportDownloadExpirationSeconds').isInt(),
+    ],
     maintenanceMode: [
       body('flag').isBoolean(),
     ],
@@ -437,6 +441,7 @@ module.exports = (crowi) => {
       globalLang: configManager.getConfig('app:globalLang'),
       isEmailPublishedForNewUser: configManager.getConfig('customize:isEmailPublishedForNewUser'),
       fileUpload: configManager.getConfig('app:fileUpload'),
+      useOnlyEnvVarsForIsBulkExportPagesEnabled: configManager.getConfig('env:useOnlyEnvVars:app:isBulkExportPagesEnabled'),
       isV5Compatible: configManager.getConfig('app:isV5Compatible'),
       siteUrl: configManager.getConfig('app:siteUrl'),
       siteUrlUseOnlyEnvVars: configManager.getConfig('env:useOnlyEnvVars:app:siteUrl'),
@@ -492,12 +497,17 @@ module.exports = (crowi) => {
       isAppSiteUrlHashed: configManager.getConfig('questionnaire:isAppSiteUrlHashed'),
 
       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 });
 
   });
 
-
   /**
    * @swagger
    *
@@ -1020,6 +1030,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
    *

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

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

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

@@ -10,6 +10,7 @@ import multer from 'multer';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { isG2GTransferError } from '~/server/models/vo/g2g-transfer-error';
 import { configManager } from '~/server/service/config-manager';
+import { exportService } from '~/server/service/export';
 import type { IDataGROWIInfo } from '~/server/service/g2g-transfer';
 import { X_GROWI_TRANSFER_KEY_HEADER_NAME } from '~/server/service/g2g-transfer';
 import { getImportService } from '~/server/service/import';
@@ -41,7 +42,7 @@ const validator = {
  */
 module.exports = (crowi: Crowi): Router => {
   const {
-    g2gTransferPusherService, g2gTransferReceiverService, exportService,
+    g2gTransferPusherService, g2gTransferReceiverService,
     growiBridgeService,
   } = crowi;
 
@@ -172,9 +173,9 @@ module.exports = (crowi: Crowi): Router => {
       const zipFile = importService.getFile(file.filename);
       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) {
       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('/questionnaire', require('~/features/questionnaire/server/routes/apiv3/questionnaire')(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));
 

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

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

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

@@ -44,6 +44,8 @@ const router = express.Router();
  *            type: string
  *          isEmailPublished:
  *            type: boolean
+ *          slackMemberId:
+ *            type: string
  *      Passwords:
  *        description: passwords for update
  *        type: object
@@ -188,6 +190,10 @@ module.exports = (crowi) => {
    *                  properties:
    *                    isPasswordSet:
    *                      type: boolean
+   *                      description: Whether a password has been set
+   *                    minPasswordLength:
+   *                      type: number
+   *                      description: Minimum password length
    */
   router.get('/is-password-set', accessTokenParser(), loginRequiredStrictly, async(req, res) => {
     const { username } = req.user;
@@ -227,7 +233,7 @@ module.exports = (crowi) => {
    *              application/json:
    *                schema:
    *                  properties:
-   *                    currentUser:
+   *                    updatedUser:
    *                      type: object
    *                      description: personal params
    */
@@ -271,6 +277,13 @@ module.exports = (crowi) => {
    *        operationId: putUserImageType
    *        summary: /personal-setting/image-type
    *        description: Update user image type
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              properties:
+   *                isGravatarEnabled:
+   *                  type: boolean
    *        responses:
    *          200:
    *            description: succeded to update user image type
@@ -346,8 +359,11 @@ module.exports = (crowi) => {
    *          required: true
    *          content:
    *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/Passwords'
+   *              properties:
+   *                oldPassword:
+   *                  type: string
+   *                newPassword:
+   *                  type: string
    *        responses:
    *          200:
    *            description: user password
@@ -387,6 +403,8 @@ module.exports = (crowi) => {
    *    /personal-setting/api-token:
    *      put:
    *        tags: [GeneralSetting]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: putUserApiToken
    *        summary: /personal-setting/api-token
    *        description: Update user api token
@@ -516,7 +534,9 @@ module.exports = (crowi) => {
    *          content:
    *            application/json:
    *              schema:
-   *                $ref: '#/components/schemas/AssociateUser'
+   *                properties:
+   *                  username:
+   *                    type: string
    *        responses:
    *          200:
    *            description: succeded to associate Ldap account
@@ -612,18 +632,30 @@ module.exports = (crowi) => {
    *      put:
    *        tags: [EditorSetting]
    *        operationId: putEditorSettings
-   *        summary: /editor-setting
+   *        summary: /personal-setting/editor-settings
    *        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:
    *          200:
    *            description: params of editor settings
    *            content:
    *              application/json:
    *                schema:
-   *                  properties:
-   *                    currentUser:
-   *                      type: object
-   *                      description: editor settings
+   *                  type: object
+   *                  description: editor settings
    */
   router.put('/editor-settings', accessTokenParser(), loginRequiredStrictly, addActivity, validator.editorSettings, apiV3FormValidator, async(req, res) => {
     const query = { userId: req.user.id };
@@ -662,7 +694,7 @@ module.exports = (crowi) => {
    *      get:
    *        tags: [EditorSetting]
    *        operationId: getEditorSettings
-   *        summary: /editor-setting
+   *        summary: /personal-setting/editor-settings
    *        description: Get editor preferences
    *        responses:
    *          200:
@@ -670,10 +702,8 @@ module.exports = (crowi) => {
    *            content:
    *              application/json:
    *                schema:
-   *                  properties:
-   *                    currentUser:
-   *                      type: object
-   *                      description: editor settings
+   *                  type: object
+   *                  description: editor settings
    */
   router.get('/editor-settings', accessTokenParser(), loginRequiredStrictly, async(req, res) => {
     try {
@@ -694,18 +724,30 @@ module.exports = (crowi) => {
    *      put:
    *        tags: [InAppNotificationSettings]
    *        operationId: putInAppNotificationSettings
-   *        summary: personal-setting/in-app-notification-settings
+   *        summary: /personal-setting/in-app-notification-settings
    *        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:
    *          200:
    *            description: params of InAppNotificationSettings
    *            content:
    *              application/json:
    *                schema:
-   *                  properties:
-   *                    currentUser:
-   *                      type: object
-   *                      description: in-app-notification-settings
+   *                 type: object
    */
   // eslint-disable-next-line max-len
   router.put('/in-app-notification-settings', accessTokenParser(), loginRequiredStrictly, addActivity, validator.inAppNotificationSettings, apiV3FormValidator, async(req, res) => {
@@ -763,6 +805,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
   router.put('/questionnaire-settings', accessTokenParser(), loginRequiredStrictly, validator.questionnaireSettings, apiV3FormValidator, async(req, res) => {
     const { isQuestionnaireEnabled } = req.body;

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

@@ -122,6 +122,18 @@ const validator = {
  *          restrictGuestMode:
  *            type: string
  *            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:
  *            type: string
  *            description: type of pageDeletionAuthority
@@ -131,6 +143,21 @@ const validator = {
  *          hideRestrictedByGroup:
  *            type: boolean
  *            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:
  *        type: object
  *        properties:
@@ -140,6 +167,15 @@ const validator = {
  *      LocalSetting:
  *        type: object
  *        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:
  *            type: boolean
  *            description: local setting mode
@@ -152,6 +188,27 @@ const validator = {
  *            items:
  *              type: string
  *              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:
  *        type: object
  *        properties:
@@ -194,15 +251,30 @@ const validator = {
  *      SamlAuthSetting:
  *        type: object
  *        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:
  *            type: string
  *            description: entry point for saml
  *          samlIssuer:
  *            type: string
  *            description: issuer for saml
+ *          samlEnvVarIssuer:
+ *            type: string
+ *            description: issuer for saml
  *          samlCert:
  *            type: string
  *            description: certificate for saml
+ *          samlEnvVarCert:
+ *            type: string
+ *            desription: certificate for saml
  *          samlAttrMapId:
  *            type: string
  *            description: attribute mapping id for saml
@@ -212,12 +284,27 @@ const validator = {
  *          samlAttrMapMail:
  *            type: string
  *            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:
  *            type: string
  *            description: attribute mapping first name for saml
  *          samlAttrMapLastName:
  *            type: string
  *            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:
  *            type: boolean
  *            description: local account automatically linked the user name matched
@@ -227,6 +314,9 @@ const validator = {
  *          samlABLCRule:
  *            type: string
  *            description: ABLCRule for saml
+ *          samlEnvVarABLCRule:
+ *            type: string
+ *            description: ABLCRule for saml
  *      OidcAuthSetting:
  *        type: object
  *        properties:
@@ -344,6 +434,25 @@ module.exports = (crowi) => {
    *                    securityParams:
    *                      type: object
    *                      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('/', loginRequiredStrictly, adminRequired, async(req, res) => {
 
@@ -471,7 +580,7 @@ module.exports = (crowi) => {
    *                properties:
    *                  isEnabled:
    *                    type: boolean
-   *                  target:
+   *                  authId:
    *                    type: string
    *        responses:
    *          200:
@@ -576,6 +685,9 @@ module.exports = (crowi) => {
    *    /security-setting/authentication:
    *      get:
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/authentication
    *        description: Get setup strategies for passport
    *        responses:
    *          200:
@@ -604,6 +716,9 @@ module.exports = (crowi) => {
    *    /security-setting/general-setting:
    *      put:
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/general-setting
    *        description: Update GeneralSetting
    *        requestBody:
    *          required: true
@@ -690,6 +805,9 @@ module.exports = (crowi) => {
    *    /security-setting/share-link-setting:
    *      put:
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/share-link-setting
    *        description: Update ShareLink Setting
    *        requestBody:
    *          required: true
@@ -703,7 +821,9 @@ module.exports = (crowi) => {
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/ShareLinkSetting'
+   *                  properties:
+   *                    securitySettingParams:
+   *                      $ref: '#/components/schemas/ShareLinkSetting'
    */
   router.put('/share-link-setting', loginRequiredStrictly, adminRequired, addActivity, validator.generalSetting, apiV3FormValidator, async(req, res) => {
     const updateData = {
@@ -733,6 +853,9 @@ module.exports = (crowi) => {
    *    /security-setting/all-share-links:
    *      get:
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/all-share-links
    *        description: Get All ShareLinks at Share Link Setting
    *        responses:
    *          200:
@@ -776,12 +899,21 @@ module.exports = (crowi) => {
    *    /security-setting/all-share-links:
    *      delete:
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/all-share-links
    *        description: Delete All ShareLinks at Share Link Setting
    *        responses:
    *          200:
    *            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/', loginRequiredStrictly, adminRequired, async(req, res) => {
     try {
       const removedAct = await ShareLink.remove({});
@@ -801,6 +933,9 @@ module.exports = (crowi) => {
    *    /security-setting/local-setting:
    *      put:
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/local-setting
    *        description: Update LocalSetting
    *        requestBody:
    *          required: true
@@ -814,7 +949,9 @@ module.exports = (crowi) => {
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/LocalSetting'
+   *                  properties:
+   *                    localSettingParams:
+   *                      $ref: '#/components/schemas/LocalSetting'
    */
   router.put('/local-setting', loginRequiredStrictly, adminRequired, addActivity, validator.localSetting, apiV3FormValidator, async(req, res) => {
     try {
@@ -853,6 +990,9 @@ module.exports = (crowi) => {
    *    /security-setting/ldap:
    *      put:
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/ldap
    *        description: Update LDAP setting
    *        requestBody:
    *          required: true
@@ -866,7 +1006,9 @@ module.exports = (crowi) => {
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/LdapAuthSetting'
+   *                  properties:
+   *                    securitySettingParams:
+   *                      $ref: '#/components/schemas/LdapAuthSetting'
    */
   router.put('/ldap', loginRequiredStrictly, adminRequired, addActivity, validator.ldapAuth, apiV3FormValidator, async(req, res) => {
     const requestParams = {
@@ -918,6 +1060,9 @@ module.exports = (crowi) => {
    *    /security-setting/saml:
    *      put:
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/saml
    *        description: Update SAML setting
    *        requestBody:
    *          required: true
@@ -931,7 +1076,9 @@ module.exports = (crowi) => {
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/SamlAuthSetting'
+   *                  properties:
+   *                    securitySettingParams:
+   *                      $ref: '#/components/schemas/SamlAuthSetting'
    */
   router.put('/saml', loginRequiredStrictly, adminRequired, addActivity, validator.samlAuth, apiV3FormValidator, async(req, res) => {
     const { t } = await getTranslation({ lang: req.user.lang, ns: ['translation', 'admin'] });
@@ -1012,6 +1159,9 @@ module.exports = (crowi) => {
    *    /security-setting/oidc:
    *      put:
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/oidc
    *        description: Update OpenID Connect setting
    *        requestBody:
    *          required: true
@@ -1025,7 +1175,9 @@ module.exports = (crowi) => {
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/OidcAuthSetting'
+   *                  properties:
+   *                    securitySettingParams:
+   *                      $ref: '#/components/schemas/OidcAuthSetting'
    */
   router.put('/oidc', loginRequiredStrictly, adminRequired, addActivity, validator.oidcAuth, apiV3FormValidator, async(req, res) => {
     const requestParams = {
@@ -1089,6 +1241,9 @@ module.exports = (crowi) => {
    *    /security-setting/google-oauth:
    *      put:
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/google-oauth
    *        description: Update google OAuth
    *        requestBody:
    *          required: true
@@ -1102,7 +1257,9 @@ module.exports = (crowi) => {
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/GoogleOAuthSetting'
+   *                  properties:
+   *                    securitySettingParams:
+   *                      $ref: '#/components/schemas/GoogleOAuthSetting'
    */
   router.put('/google-oauth', loginRequiredStrictly, adminRequired, addActivity, validator.googleOAuth, apiV3FormValidator, async(req, res) => {
     const requestParams = {
@@ -1137,6 +1294,9 @@ module.exports = (crowi) => {
    *    /security-setting/github-oauth:
    *      put:
    *        tags: [SecuritySetting]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /security-setting/github-oauth
    *        description: Update github OAuth
    *        requestBody:
    *          required: true
@@ -1150,7 +1310,9 @@ module.exports = (crowi) => {
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/GitHubOAuthSetting'
+   *                  properties:
+   *                    securitySettingParams:
+   *                      $ref: '#/components/schemas/GitHubOAuthSetting'
    */
   router.put('/github-oauth', loginRequiredStrictly, adminRequired, addActivity, validator.githubOAuth, apiV3FormValidator, async(req, res) => {
     const requestParams = {

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

@@ -163,11 +163,39 @@ module.exports = (crowi) => {
    *      get:
    *        tags: [SlackIntegrationSettings]
    *        operationId: getSlackBotSettingParams
-   *        summary: /slack-integration
+   *        summary: /slack-integration-settings
    *        description: Get current settings and connection statuses.
    *        responses:
    *          200:
    *            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(), loginRequiredStrictly, adminRequired, async(req, res) => {
 
@@ -361,9 +389,21 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/without-proxy/update-settings/:
    *      put:
    *        tags: [SlackIntegrationSettings (without proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: putWithoutProxySettings
-   *        summary: update customBotWithoutProxy settings
+   *        summary: /slack-integration-settings/without-proxy/update-settings
    *        description: Update customBotWithoutProxy setting.
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  slackSigningSecret:
+   *                    type: string
+   *                  slackBotToken:
+   *                    type: string
    *        responses:
    *           200:
    *             description: Succeeded to put CustomBotWithoutProxy setting.
@@ -401,9 +441,21 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/without-proxy/update-permissions/:
    *      put:
    *        tags: [SlackIntegrationSettings (without proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: putWithoutProxyPermissions
-   *        summary: update customBotWithoutProxy permissions
+   *        summary: /slack-integration-settings/without-proxy/update-permissions
    *        description: Update customBotWithoutProxy permissions.
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  commandPermission:
+   *                    type: object
+   *                  eventActionsPermission:
+   *                    type: object
    *        responses:
    *           200:
    *             description: Succeeded to put CustomBotWithoutProxy permissions.
@@ -444,12 +496,29 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/slack-app-integrations:
    *      post:
    *        tags: [SlackIntegrationSettings (with proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: putSlackAppIntegrations
-   *        summary: /slack-integration
+   *        summary: /slack-integration-settings/slack-app-integrations
    *        description: Generate SlackAppIntegrations
    *        responses:
    *          200:
    *            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', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const SlackAppIntegrationRecordsNum = await SlackAppIntegration.countDocuments();
@@ -494,12 +563,25 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/slack-app-integrations/:id:
    *      delete:
    *        tags: [SlackIntegrationSettings (with proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: deleteAccessTokens
-   *        summary: delete accessTokens
+   *        summary: /slack-integration-settings/slack-app-integrations/:id
    *        description: Delete accessTokens
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            schema:
+   *              type: string
    *        responses:
    *          200:
    *            description: Succeeded to delete access tokens for slack
+   *            content:
+   *              application/json:
+   *                properties:
+   *                  response:
+   *                    type: object
    */
   router.delete('/slack-app-integrations/:id', loginRequiredStrictly, adminRequired, validator.deleteIntegration, apiV3FormValidator, addActivity,
     async(req, res) => {
@@ -525,6 +607,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', loginRequiredStrictly, adminRequired, addActivity, validator.proxyUri, apiV3FormValidator, async(req, res) => {
     const { proxyUri } = req.body;
 
@@ -552,9 +660,17 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/slack-app-integrations/:id/makeprimary:
    *      put:
    *        tags: [SlackIntegrationSettings (with proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: makePrimary
-   *        summary: /slack-integration
+   *        summary: /slack-integration-settings/slack-app-integrations/:id/makeprimary
    *        description: Make SlackAppTokens primary
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            schema:
+   *              type: string
    *        responses:
    *          200:
    *            description: Succeeded to make it primary
@@ -599,12 +715,24 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/slack-app-integrations/:id/regenerate-tokens:
    *      put:
    *        tags: [SlackIntegrationSettings (with proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: putRegenerateTokens
-   *        summary: /slack-integration
+   *        summary: /slack-integration-settings/slack-app-integrations/:id/regenerate-tokens
    *        description: Regenerate SlackAppTokens
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            schema:
+   *              type: string
    *        responses:
    *          200:
    *            description: Succeeded to regenerate slack app tokens
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
    */
   // eslint-disable-next-line max-len
   router.put('/slack-app-integrations/:id/regenerate-tokens', loginRequiredStrictly, adminRequired, addActivity, validator.regenerateTokens, apiV3FormValidator, async(req, res) => {
@@ -632,12 +760,36 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/slack-app-integrations/:id/permissions:
    *      put:
    *        tags: [SlackIntegrationSettings (with proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: putSupportedCommands
-   *        summary: /slack-integration-settings/:id/permissions
+   *        summary: /slack-integration-settings/slack-app-integrations/:id/permissions
    *        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:
    *          200:
    *            description: Succeeded to update supported commands
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
    */
   // eslint-disable-next-line max-len
   router.put('/slack-app-integrations/:id/permissions', loginRequiredStrictly, adminRequired, addActivity, validator.updatePermissionsWithProxy, apiV3FormValidator, async(req, res) => {
@@ -691,9 +843,25 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/slack-app-integrations/:id/relation-test:
    *      post:
    *        tags: [SlackIntegrationSettings (with proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: postRelationTest
-   *        summary: Test relation
+   *        summary: /slack-integration-settings/slack-app-integrations/:id/relation-test
    *        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:
    *           200:
    *             description: Succeeded to delete botType setting.
@@ -762,15 +930,17 @@ module.exports = (crowi) => {
    *    /slack-integration-settings/without-proxy/test:
    *      post:
    *        tags: [SlackIntegrationSettings (without proxy)]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: postTest
-   *        summary: test the connection
+   *        summary: /slack-integration-settings/without-proxy/test
    *        description: Test the connection with slack work space.
    *        requestBody:
    *          content:
    *            application/json:
    *              schema:
    *                properties:
-   *                  testChannel:
+   *                  channel:
    *                    type: string
    *        responses:
    *           200:

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

@@ -89,9 +89,36 @@ module.exports = (crowi) => {
    *    /user-groups:
    *      get:
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getUserGroup
    *        summary: /user-groups
    *        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:
    *          200:
    *            description: usergroups are fetched
@@ -102,6 +129,12 @@ module.exports = (crowi) => {
    *                    userGroups:
    *                      type: object
    *                      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('/', loginRequiredStrictly, adminRequired, async(req, res) => {
     const { query } = req;
@@ -129,11 +162,13 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /ancestors:
+   *    /user-groups/ancestors:
    *      get:
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getAncestorUserGroups
-   *        summary: /ancestors
+   *        summary: /user-groups/ancestors
    *        description: Get ancestor user groups.
    *        parameters:
    *          - name: groupId
@@ -149,7 +184,7 @@ module.exports = (crowi) => {
    *              application/json:
    *                schema:
    *                  properties:
-   *                    userGroups:
+   *                    ancestorUserGroups:
    *                      type: array
    *                      items:
    *                        type: object
@@ -170,6 +205,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', loginRequiredStrictly, adminRequired, validator.listChildren, async(req, res) => {
     try {
       const { parentIds, includeGrandChildren = false } = req.query;
@@ -195,6 +274,8 @@ module.exports = (crowi) => {
    *    /user-groups:
    *      post:
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: createUserGroup
    *        summary: /user-groups
    *        description: Adds userGroup
@@ -207,6 +288,12 @@ module.exports = (crowi) => {
    *                  name:
    *                    type: string
    *                    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:
    *          200:
    *            description: userGroup is added
@@ -245,6 +332,8 @@ module.exports = (crowi) => {
    *    /selectable-parent-groups:
    *      get:
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getSelectableParentGroups
    *        summary: /selectable-parent-groups
    *        description: Get selectable parent UserGroups
@@ -262,7 +351,7 @@ module.exports = (crowi) => {
    *              application/json:
    *                schema:
    *                  properties:
-   *                    userGroups:
+   *                    selectableParentGroups:
    *                      type: array
    *                      items:
    *                        type: object
@@ -294,6 +383,8 @@ module.exports = (crowi) => {
    *    /selectable-child-groups:
    *      get:
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getSelectableChildGroups
    *        summary: /selectable-child-groups
    *        description: Get selectable child UserGroups
@@ -311,7 +402,7 @@ module.exports = (crowi) => {
    *              application/json:
    *                schema:
    *                  properties:
-   *                    userGroups:
+   *                    selectableChildGroups:
    *                      type: array
    *                      items:
    *                        type: object
@@ -346,6 +437,8 @@ module.exports = (crowi) => {
    *    /user-groups/{id}:
    *      get:
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getUserGroupFromGroupId
    *        summary: /user-groups/{id}
    *        description: Get UserGroup from Group ID
@@ -388,6 +481,8 @@ module.exports = (crowi) => {
    *    /user-groups/{id}:
    *      delete:
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: deleteUserGroup
    *        summary: /user-groups/{id}
    *        description: Deletes userGroup
@@ -408,6 +503,11 @@ module.exports = (crowi) => {
    *            description: userGroup id that will be transferred to
    *            schema:
    *              type: string
+   *          - name: transferToUserGroupType
+   *            in: query
+   *            description: userGroup type that will be transferred to
+   *            schema:
+   *              type: string
    *        responses:
    *          200:
    *            description: userGroup is removed
@@ -452,6 +552,8 @@ module.exports = (crowi) => {
    *    /user-groups/{id}:
    *      put:
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: updateUserGroups
    *        summary: /user-groups/{id}
    *        description: Update userGroup
@@ -462,6 +564,24 @@ module.exports = (crowi) => {
    *            description: id of userGroup
    *            schema:
    *              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:
    *          200:
    *            description: userGroup is updated
@@ -502,6 +622,8 @@ module.exports = (crowi) => {
    *    /user-groups/{id}/users:
    *      get:
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getUsersUserGroups
    *        summary: /user-groups/{id}/users
    *        description: Get users related to the userGroup
@@ -522,7 +644,7 @@ module.exports = (crowi) => {
    *                    users:
    *                      type: array
    *                      items:
-   *                        type: object
+   *                        $ref: '#/components/schemas/User'
    *                      description: user objects
    */
   router.get('/:id/users', loginRequiredStrictly, adminRequired, async(req, res) => {
@@ -553,6 +675,8 @@ module.exports = (crowi) => {
    *    /user-groups/{id}/unrelated-users:
    *      get:
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getUnrelatedUsersUserGroups
    *        summary: /user-groups/{id}/unrelated-users
    *        description: Get users unrelated to the userGroup
@@ -563,6 +687,26 @@ module.exports = (crowi) => {
    *            description: id of userGroup
    *            schema:
    *              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:
    *          200:
    *            description: users are fetched
@@ -573,7 +717,7 @@ module.exports = (crowi) => {
    *                    users:
    *                      type: array
    *                      items:
-   *                        type: object
+   *                        $ref: '#/components/schemas/User'
    *                      description: user objects
    */
   router.get('/:id/unrelated-users', loginRequiredStrictly, adminRequired, async(req, res) => {
@@ -611,11 +755,13 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /user-groups/{id}/users:
+   *    /user-groups/{id}/users/{username}:
    *      post:
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: addUserUserGroups
-   *        summary: /user-groups/{id}/users
+   *        summary: /user-groups/{id}/users/{username}
    *        description: Add a user to the userGroup
    *        parameters:
    *          - name: id
@@ -624,6 +770,12 @@ module.exports = (crowi) => {
    *            description: id of userGroup
    *            schema:
    *              type: string
+   *          - name: username
+   *            in: path
+   *            required: true
+   *            description: username of the user
+   *            schema:
+   *              type: string
    *        responses:
    *          200:
    *            description: a user is added
@@ -633,14 +785,11 @@ module.exports = (crowi) => {
    *                  type: object
    *                  properties:
    *                    user:
-   *                      type: object
+   *                      $ref: '#/components/schemas/User'
    *                      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', loginRequiredStrictly, adminRequired, validator.users.post, apiV3FormValidator, addActivity, async(req, res) => {
     const { id, username } = req.params;
@@ -678,11 +827,13 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /user-groups/{id}/users:
+   *    /user-groups/{id}/users/{username}:
    *      delete:
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: deleteUsersUserGroups
-   *        summary: /user-groups/{id}/users
+   *        summary: /user-groups/{id}/users/{username}
    *        description: remove a user from the userGroup
    *        parameters:
    *          - name: id
@@ -691,6 +842,12 @@ module.exports = (crowi) => {
    *            description: id of userGroup
    *            schema:
    *              type: string
+   *          - name: username
+   *            in: path
+   *            required: true
+   *            description: username of the user
+   *            schema:
+   *              type: string
    *        responses:
    *          200:
    *            description: a user was removed
@@ -701,13 +858,11 @@ module.exports = (crowi) => {
    *                  properties:
    *                    user:
    *                      type: object
+   *                      $ref: '#/components/schemas/User'
    *                      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', loginRequiredStrictly, adminRequired, validator.users.delete, apiV3FormValidator, async(req, res) => {
     const { id: userGroupId, username } = req.params;
@@ -733,6 +888,8 @@ module.exports = (crowi) => {
    *    /user-groups/{id}/user-group-relations:
    *      get:
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getUserGroupRelationsUserGroups
    *        summary: /user-groups/{id}/user-group-relations
    *        description: Get the user group relations for the userGroup
@@ -780,6 +937,8 @@ module.exports = (crowi) => {
    *    /user-groups/{id}/pages:
    *      get:
    *        tags: [UserGroups]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: getPagesUserGroups
    *        summary: /user-groups/{id}/pages
    *        description: Get closed pages for the userGroup

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

@@ -235,6 +235,11 @@ module.exports = (crowi) => {
    *            description: sorting column
    *            schema:
    *              type: string
+   *          - name: forceIncludeAttributes
+   *            in: query
+   *            description: force include attributes
+   *            schema:
+   *              type: string
    *        responses:
    *          200:
    *            description: users are fetched
@@ -406,6 +411,8 @@ module.exports = (crowi) => {
    *    /users/invite:
    *      post:
    *        tags: [Users Management]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: inviteUser
    *        summary: /users/invite
    *        description: Create new users and send Emails
@@ -428,14 +435,23 @@ module.exports = (crowi) => {
    *                schema:
    *                  properties:
    *                    createdUserList:
-   *                      type: object
+   *                      $ref: '#/components/schemas/User'
    *                      description: Users successfully created
    *                    existingEmailList:
-   *                      type: object
+   *                      type: array
    *                      description: Users email that already exists
+   *                      items:
+   *                        type: string
    *                    failedEmailList:
    *                      type: object
    *                      description: Users email that failed to create or send email
+   *                      properties:
+   *                        email:
+   *                          type: string
+   *                          description: email address
+   *                        reason:
+   *                          type: string
+   *                          description: reason for failure
    */
   router.post('/invite', loginRequiredStrictly, adminRequired, addActivity, validator.inviteEmail, apiV3FormValidator, async(req, res) => {
 
@@ -474,6 +490,8 @@ module.exports = (crowi) => {
    *    /users/{id}/grant-admin:
    *      put:
    *        tags: [Users Management]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: grantAdminUser
    *        summary: /users/{id}/grant-admin
    *        description: Grant user admin
@@ -492,7 +510,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  properties:
    *                    userData:
-   *                      type: object
+   *                      $ref: '#/components/schemas/User'
    *                      description: data of admin user
    */
   router.put('/:id/grant-admin', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
@@ -521,6 +539,8 @@ module.exports = (crowi) => {
    *    /users/{id}/revoke-admin:
    *      put:
    *        tags: [Users Management]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: revokeAdminUser
    *        summary: /users/{id}/revoke-admin
    *        description: Revoke user admin
@@ -568,6 +588,8 @@ module.exports = (crowi) => {
    *    /users/{id}/grant-read-only:
    *      put:
    *        tags: [Users Management]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: ReadOnly
    *        summary: /users/{id}/grant-read-only
    *        description: Grant user read only access
@@ -586,8 +608,8 @@ module.exports = (crowi) => {
    *                schema:
    *                  properties:
    *                    userData:
-   *                      type: object
-   *                      description: data of read only
+   *                      $ref: '#/components/schemas/User'
+   *                      description: data of grant read only
    */
   router.put('/:id/grant-read-only', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.params;
@@ -620,6 +642,8 @@ module.exports = (crowi) => {
    *    /users/{id}/revoke-read-only:
    *      put:
    *        tags: [Users Management]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: revokeReadOnly
    *        summary: /users/{id}/revoke-read-only
    *        description: Revoke user read only access
@@ -638,7 +662,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  properties:
    *                    userData:
-   *                      type: object
+   *                      $ref: '#/components/schemas/User'
    *                      description: data of revoke read only
    */
   router.put('/:id/revoke-read-only', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
@@ -672,6 +696,8 @@ module.exports = (crowi) => {
    *    /users/{id}/activate:
    *      put:
    *        tags: [Users Management]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: activateUser
    *        summary: /users/{id}/activate
    *        description: Activate user
@@ -690,7 +716,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  properties:
    *                    userData:
-   *                      type: object
+   *                      $ref: '#/components/schemas/User'
    *                      description: data of activate user
    */
   router.put('/:id/activate', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
@@ -726,6 +752,8 @@ module.exports = (crowi) => {
    *    /users/{id}/deactivate:
    *      put:
    *        tags: [Users Management]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: deactivateUser
    *        summary: /users/{id}/deactivate
    *        description: Deactivate user
@@ -744,7 +772,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  properties:
    *                    userData:
-   *                      type: object
+   *                      $ref: '#/components/schemas/User'
    *                      description: data of deactivate user
    */
   router.put('/:id/deactivate', loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity, async(req, res) => {
@@ -773,6 +801,8 @@ module.exports = (crowi) => {
    *    /users/{id}/remove:
    *      delete:
    *        tags: [Users Management]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: removeUser
    *        summary: /users/{id}/remove
    *        description: Delete user
@@ -791,7 +821,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  properties:
    *                    user:
-   *                      type: object
+   *                      $ref: '#/components/schemas/User'
    *                      description: data of deleted user
    */
   router.delete('/:id/remove', loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity, async(req, res) => {
@@ -835,9 +865,17 @@ module.exports = (crowi) => {
    *    /users/external-accounts:
    *      get:
    *        tags: [Users Management]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: listExternalAccountsUsers
    *        summary: /users/external-accounts
    *        description: Get external-account
+   *        parameters:
+   *          - name: page
+   *            in: query
+   *            description: page number
+   *            schema:
+   *              type: number
    *        responses:
    *          200:
    *            description: external-account are fetched
@@ -868,6 +906,8 @@ module.exports = (crowi) => {
    *    /users/external-accounts/{id}/remove:
    *      delete:
    *        tags: [Users Management]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: removeExternalAccountUser
    *        summary: /users/external-accounts/{id}/remove
    *        description: Delete ExternalAccount
@@ -911,25 +951,29 @@ module.exports = (crowi) => {
    *    /users/update.imageUrlCache:
    *      put:
    *        tags: [Users Management]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: update.imageUrlCache
    *        summary: /users/update.imageUrlCache
    *        description: update imageUrlCache
-   *        parameters:
-   *          - name:  userIds
-   *            in: query
-   *            description: user id list
+   *        requestBody:
+   *          content:
+   *           application/json:
    *            schema:
-   *              type: string
+   *             properties:
+   *              userIds:
+   *                type: array
+   *                description: user id list
+   *                items:
+   *                  type: string
    *        responses:
    *          200:
    *            description: success creating imageUrlCached
    *            content:
    *              application/json:
    *                schema:
-   *                  properties:
-   *                    userData:
-   *                      type: object
-   *                      description: users updated with imageUrlCached
+   *                  type: object
+   *                  description: success creating imageUrlCached
    */
   router.put('/update.imageUrlCache', loginRequiredStrictly, adminRequired, async(req, res) => {
     try {
@@ -963,6 +1007,8 @@ module.exports = (crowi) => {
    *    /users/reset-password:
    *      put:
    *        tags: [Users Management]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: resetPassword
    *        summary: /users/reset-password
    *        description: update imageUrlCache
@@ -971,14 +1017,21 @@ module.exports = (crowi) => {
    *            application/json:
    *              schema:
    *                properties:
-   *                  newPassword:
-   *                    type: string
-   *                  user:
+   *                  id:
    *                    type: string
    *                    description: user id for reset password
    *        responses:
    *          200:
    *            description: success reset password
+   *            content:
+   *              application/json:
+   *                schema:
+   *                 properties:
+   *                  newPassword:
+   *                    type: string
+   *                    description: new password
+   *                  user:
+   *                    $ref: '#/components/schemas/User'
    */
   router.put('/reset-password', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.body;
@@ -1004,6 +1057,8 @@ module.exports = (crowi) => {
    *    /users/reset-password-email:
    *      put:
    *        tags: [Users Management]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: resetPasswordEmail
    *        summary: /users/reset-password-email
    *        description: send new password email
@@ -1012,11 +1067,11 @@ module.exports = (crowi) => {
    *            application/json:
    *              schema:
    *                properties:
-   *                  newPassword:
-   *                    type: string
-   *                  user:
+   *                  id:
    *                    type: string
    *                    description: user id for send new password email
+   *                  newPassword:
+   *                    type: string
    *        responses:
    *          200:
    *            description: success send new password email
@@ -1051,6 +1106,8 @@ module.exports = (crowi) => {
    *    /users/send-invitation-email:
    *      put:
    *        tags: [Users Management]
+   *        security:
+   *          - cookieAuth: []
    *        operationId: sendInvitationEmail
    *        summary: /users/send-invitation-email
    *        description: send invitation email
@@ -1072,6 +1129,11 @@ module.exports = (crowi) => {
    *                    failedToSendEmail:
    *                      type: object
    *                      description: email and reasons for email sending failure
+   *                      properties:
+   *                        email:
+   *                          type: string
+   *                        reason:
+   *                          type: string
    */
   router.put('/send-invitation-email', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.body;
@@ -1160,6 +1222,81 @@ module.exports = (crowi) => {
     return res.apiv3(data);
   });
 
+  /**
+    * @swagger
+    *
+    *    paths:
+    *      /users/usernames:
+    *        get:
+    *          tags: [Users]
+    *          summary: /users/usernames
+    *          operationId: getUsernames
+    *          description: Get list of usernames
+    *          parameters:
+    *            - in: query
+    *              name: q
+    *              schema:
+    *                type: string
+    *                description: query string to search usernames
+    *                example: alice
+    *            - in: query
+    *              name: offset
+    *              schema:
+    *                type: integer
+    *                description: offset for pagination
+    *                example: 0
+    *            - in: query
+    *              name: limit
+    *              schema:
+    *                type: integer
+    *                description: limit for pagination
+    *                example: 10
+    *            - in: query
+    *              name: options
+    *              schema:
+    *                type: string
+    *                description: options for including different types of users
+    *                example: '{"isIncludeActiveUser": true, "isIncludeInactiveUser": true,
+    *                          "isIncludeActivitySnapshotUser": true, "isIncludeMixedUsernames": true}'
+    *          responses:
+    *            200:
+    *              description: Succeeded to get list of usernames.
+    *              content:
+    *                application/json:
+    *                  schema:
+    *                    properties:
+    *                      activeUser:
+    *                        type: object
+    *                        properties:
+    *                          usernames:
+    *                            type: array
+    *                            items:
+    *                              type: string
+    *                          totalCount:
+    *                            type: integer
+    *                      inactiveUser:
+    *                        type: object
+    *                        properties:
+    *                          usernames:
+    *                            type: array
+    *                            items:
+    *                              type: string
+    *                          totalCount:
+    *                            type: integer
+    *                      activitySnapshotUser:
+    *                        type: object
+    *                        properties:
+    *                          usernames:
+    *                            type: array
+    *                            items:
+    *                              type: string
+    *                          totalCount:
+    *                            type: integer
+    *                      mixedUsernames:
+    *                        type: array
+    *                        items:
+    *                          type: string
+    */
   router.get('/usernames', accessTokenParser(), loginRequired, validator.usernames, apiV3FormValidator, async(req, res) => {
     const q = req.query.q;
     const offset = +req.query.offset || 0;

+ 1 - 1
apps/app/src/server/service/attachment.js

@@ -39,7 +39,7 @@ class AttachmentService {
     // create an Attachment document and upload file
     let attachment;
     try {
-      attachment = Attachment.createWithoutSave(pageId, user, fileStream, file.originalname, file.mimetype, file.size, attachmentType);
+      attachment = Attachment.createWithoutSave(pageId, user, file.originalname, file.mimetype, file.size, attachmentType);
       await fileUploadService.uploadAttachment(fileStream, attachment);
       await attachment.save();
     }

+ 47 - 0
apps/app/src/server/service/config-manager/config-definition.ts

@@ -318,6 +318,17 @@ export const CONFIG_KEYS = [
   'env:useOnlyEnvVars:gcs',
   'env:useOnlyEnvVars:azure',
 
+  // Page Bulk Export Settings
+  'app:bulkExportJobExpirationSeconds',
+  'app:bulkExportDownloadExpirationSeconds',
+  'app:pageBulkExportJobCronSchedule',
+  'app:checkPageBulkExportJobInProgressCronSchedule',
+  'app:pageBulkExportJobCleanUpCronSchedule',
+  'app:pageBulkExportParallelExecLimit',
+  'app:pageBulkExportPdfConverterUri',
+  'app:isBulkExportPagesEnabled',
+  'env:useOnlyEnvVars:app:isBulkExportPagesEnabled',
+
 ] as const;
 
 
@@ -1285,6 +1296,42 @@ Guideline as a RAG:
     envVarName: 'AZURE_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS',
     defaultValue: false,
   }),
+  'app:bulkExportJobExpirationSeconds': defineConfig<number>({
+    envVarName: 'BULK_EXPORT_JOB_EXPIRATION_SECONDS',
+    defaultValue: 86400,
+  }),
+  'app:bulkExportDownloadExpirationSeconds': defineConfig<number>({
+    envVarName: 'BULK_EXPORT_DOWNLOAD_EXPIRATION_SECONDS',
+    defaultValue: 259200,
+  }),
+  'app:pageBulkExportJobCronSchedule': defineConfig<string>({
+    envVarName: 'BULK_EXPORT_JOB_CRON_SCHEDULE',
+    defaultValue: '*/10 * * * * *',
+  }),
+  'app:checkPageBulkExportJobInProgressCronSchedule': defineConfig<string>({
+    envVarName: 'CHECK_PAGE_BULK_EXPORT_JOB_IN_PROGRESS_CRON_SCHEDULE',
+    defaultValue: '*/3 * * * *',
+  }),
+  'app:pageBulkExportJobCleanUpCronSchedule': defineConfig<string>({
+    envVarName: 'BULK_EXPORT_JOB_CLEAN_UP_CRON_SCHEDULE',
+    defaultValue: '*/10 * * * *',
+  }),
+  'app:pageBulkExportParallelExecLimit': defineConfig<number>({
+    envVarName: 'BULK_EXPORT_PARALLEL_EXEC_LIMIT',
+    defaultValue: 5,
+  }),
+  'app:pageBulkExportPdfConverterUri': defineConfig<string | undefined>({
+    envVarName: 'BULK_EXPORT_PDF_CONVERTER_URI',
+    defaultValue: undefined,
+  }),
+  'app:isBulkExportPagesEnabled': defineConfig<boolean>({
+    envVarName: 'BULK_EXPORT_PAGES_ENABLED',
+    defaultValue: true,
+  }),
+  'env:useOnlyEnvVars:app:isBulkExportPagesEnabled': defineConfig<boolean>({
+    envVarName: 'BULK_EXPORT_PAGES_ENABLED_USES_ONLY_ENV_VARS',
+    defaultValue: false,
+  }),
 } as const;
 
 export type ConfigValues = {

+ 65 - 0
apps/app/src/server/service/cron.ts

@@ -0,0 +1,65 @@
+import type { ScheduledTask } from 'node-cron';
+import nodeCron from 'node-cron';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:cron');
+
+/**
+ * Base class for services that manage a cronjob
+ */
+abstract class CronService {
+
+  // The current cronjob to manage
+  cronJob: ScheduledTask | undefined;
+
+  /**
+   * Create and start a new cronjob
+   */
+  startCron(): void {
+    this.cronJob?.stop();
+    this.cronJob = this.generateCronJob(this.getCronSchedule());
+    this.cronJob.start();
+  }
+
+  /**
+   * Stop the current cronjob
+   */
+  stopCron(): void {
+    this.cronJob?.stop();
+    this.cronJob = undefined;
+  }
+
+  isJobRunning(): boolean {
+    return this.cronJob != null;
+  }
+
+  /**
+   * Get the cron schedule
+   * e.g. '0 1 * * *'
+   */
+  abstract getCronSchedule(): string;
+
+  /**
+   * Execute the job. Define the job process in the subclass.
+   */
+  abstract executeJob(): Promise<void>;
+
+  /**
+   * Create a new cronjob
+   * @param cronSchedule e.g. '0 1 * * *'
+   */
+  protected generateCronJob(cronSchedule: string): ScheduledTask {
+    return nodeCron.schedule(cronSchedule, async() => {
+      try {
+        await this.executeJob();
+      }
+      catch (e) {
+        logger.error(e);
+      }
+    });
+  }
+
+}
+
+export default CronService;

+ 58 - 38
apps/app/src/server/service/export.js → apps/app/src/server/service/export.ts

@@ -1,22 +1,28 @@
+import fs from 'fs';
+import path from 'path';
+import { Readable, Transform } from 'stream';
+
+import archiver from 'archiver';
+
 import { toArrayIfNot } from '~/utils/array-utils';
 import { getGrowiVersion } from '~/utils/growi-version';
 import loggerFactory from '~/utils/logger';
 
+import type CollectionProgress from '../models/vo/collection-progress';
+import CollectionProgressingStatus from '../models/vo/collection-progressing-status';
+
+import type AppService from './app';
+import type GrowiBridgeService from './growi-bridge';
+import type { ZipFileStat } from './interfaces/export';
 import { configManager } from './config-manager';
 import { growiInfoService } from './growi-info';
 
-const logger = loggerFactory('growi:services:ExportService'); // eslint-disable-line no-unused-vars
 
-const fs = require('fs');
-const path = require('path');
-const { Transform } = require('stream');
+const logger = loggerFactory('growi:services:ExportService');
 const { pipeline, finished } = require('stream/promises');
 
-const archiver = require('archiver');
 const mongoose = require('mongoose');
 
-const CollectionProgressingStatus = require('../models/vo/collection-progressing-status');
-
 class ExportProgressingStatus extends CollectionProgressingStatus {
 
   async init() {
@@ -34,17 +40,27 @@ class ExportProgressingStatus extends CollectionProgressingStatus {
 
 class ExportService {
 
-  /** @type {import('~/server/crowi').default} Crowi instance */
-  crowi;
+  crowi: any;
+
+  appService: AppService;
+
+  growiBridgeService: GrowiBridgeService;
+
+  per = 100;
+
+  zlibLevel = 9; // 0(min) - 9(max)
+
+  currentProgressingStatus: ExportProgressingStatus | null;
+
+  baseDir: string;
+
+  adminEvent: any;
 
-  /** @param {import('~/server/crowi').default} crowi Crowi instance */
   constructor(crowi) {
     this.crowi = crowi;
     this.appService = crowi.appService;
     this.growiBridgeService = crowi.growiBridgeService;
     this.baseDir = path.join(crowi.tmpDir, 'downloads');
-    this.per = 100;
-    this.zlibLevel = 9; // 0(min) - 9(max)
 
     this.adminEvent = crowi.event('admin');
 
@@ -70,7 +86,7 @@ class ExportService {
     const zipFiles = fs.readdirSync(this.baseDir).filter(file => path.extname(file) === '.zip');
 
     // process serially so as not to waste memory
-    const zipFileStats = [];
+    const zipFileStats: Array<ZipFileStat | null> = [];
     const parseZipFilePromises = zipFiles.map((file) => {
       const zipFile = this.getFile(file);
       return this.growiBridgeService.parseZipFile(zipFile);
@@ -87,7 +103,7 @@ class ExportService {
     return {
       zipFileStats: filtered,
       isExporting,
-      progressList: isExporting ? this.currentProgressingStatus.progressList : null,
+      progressList: isExporting ? this.currentProgressingStatus?.progressList : null,
     };
   }
 
@@ -97,7 +113,7 @@ class ExportService {
    * @memberOf ExportService
    * @return {string} path to meta.json
    */
-  async createMetaJson() {
+  async createMetaJson(): Promise<string> {
     const metaJson = path.join(this.baseDir, this.growiBridgeService.getMetaFileName());
     const writeStream = fs.createWriteStream(metaJson, { encoding: this.growiBridgeService.getEncoding() });
     const passwordSeed = this.crowi.env.PASSWORD_SEED || null;
@@ -123,7 +139,7 @@ class ExportService {
    * @param {ExportProgress} exportProgress
    * @return {Transform}
    */
-  generateLogStream(exportProgress) {
+  generateLogStream(exportProgress: CollectionProgress | undefined): Transform {
     const logProgress = this.logProgress.bind(this);
 
     let count = 0;
@@ -144,9 +160,9 @@ class ExportService {
    * insert beginning/ending brackets and comma separator for Json Array
    *
    * @memberOf ExportService
-   * @return {TransformStream}
+   * @return {Transform}
    */
-  generateTransformStream() {
+  generateTransformStream(): Transform {
     let isFirst = true;
 
     const transformStream = new Transform({
@@ -185,7 +201,7 @@ class ExportService {
    * @param {string} collectionName collection name
    * @return {string} path to zip file
    */
-  async exportCollectionToJson(collectionName) {
+  async exportCollectionToJson(collectionName: string): Promise<string> {
     const collection = mongoose.connection.collection(collectionName);
 
     const nativeCursor = collection.find();
@@ -195,7 +211,7 @@ class ExportService {
     const transformStream = this.generateTransformStream();
 
     // log configuration
-    const exportProgress = this.currentProgressingStatus.progressMap[collectionName];
+    const exportProgress = this.currentProgressingStatus?.progressMap[collectionName];
     const logStream = this.generateLogStream(exportProgress);
 
     // create WritableStream
@@ -204,7 +220,7 @@ class ExportService {
 
     await pipeline(readStream, logStream, transformStream, writeStream);
 
-    return writeStream.path;
+    return writeStream.path.toString();
   }
 
   /**
@@ -212,13 +228,13 @@ class ExportService {
    *
    * @memberOf ExportService
    * @param {Array.<string>} collections array of collection name
-   * @return {Array.<string>} paths to json files created
+   * @return {Array.<ZipFileStat>} info of zip file created
    */
-  async exportCollectionsToZippedJson(collections) {
+  async exportCollectionsToZippedJson(collections: string[]): Promise<ZipFileStat | null> {
     const metaJson = await this.createMetaJson();
 
     // process serially so as not to waste memory
-    const jsonFiles = [];
+    const jsonFiles: string[] = [];
     const jsonFilesPromises = collections.map(collectionName => this.exportCollectionToJson(collectionName));
     for await (const jsonFile of jsonFilesPromises) {
       jsonFiles.push(jsonFile);
@@ -245,7 +261,7 @@ class ExportService {
     // TODO: remove broken zip file
   }
 
-  async export(collections) {
+  async export(collections: string[]): Promise<ZipFileStat | null> {
     if (this.currentProgressingStatus != null) {
       throw new Error('There is an exporting process running.');
     }
@@ -253,7 +269,7 @@ class ExportService {
     this.currentProgressingStatus = new ExportProgressingStatus(collections);
     await this.currentProgressingStatus.init();
 
-    let zipFileStat;
+    let zipFileStat: ZipFileStat | null;
     try {
       zipFileStat = await this.exportCollectionsToZippedJson(collections);
     }
@@ -272,7 +288,9 @@ class ExportService {
    * @param {CollectionProgress} collectionProgress
    * @param {number} currentCount number of items exported
    */
-  logProgress(collectionProgress, currentCount) {
+  logProgress(collectionProgress: CollectionProgress | undefined, currentCount: number): void {
+    if (collectionProgress == null) return;
+
     const output = `${collectionProgress.collectionName}: ${currentCount}/${collectionProgress.totalCount} written`;
 
     // update exportProgress.currentCount
@@ -293,12 +311,11 @@ class ExportService {
   /**
    * emit progress event
    */
-  emitProgressEvent() {
-    const { currentCount, totalCount, progressList } = this.currentProgressingStatus;
+  emitProgressEvent(): void {
     const data = {
-      currentCount,
-      totalCount,
-      progressList,
+      currentCount: this.currentProgressingStatus?.currentCount,
+      totalCount: this.currentProgressingStatus?.totalCount,
+      progressList: this.currentProgressingStatus?.progressList,
     };
 
     // send event (in progress in global)
@@ -308,7 +325,7 @@ class ExportService {
   /**
    * emit start zipping event
    */
-  emitStartZippingEvent() {
+  emitStartZippingEvent(): void {
     this.adminEvent.emit('onStartZippingForExport', {});
   }
 
@@ -316,7 +333,7 @@ class ExportService {
    * emit terminate event
    * @param {object} zipFileStat added zip file status data
    */
-  emitTerminateEvent(zipFileStat) {
+  emitTerminateEvent(zipFileStat: ZipFileStat | null): void {
     this.adminEvent.emit('onTerminateForExport', { addedZipFileStat: zipFileStat });
   }
 
@@ -328,7 +345,7 @@ class ExportService {
    * @return {string} absolute path to the zip file
    * @see https://www.archiverjs.com/#quick-start
    */
-  async zipFiles(_configs) {
+  async zipFiles(_configs: {from: string, as: string}[]): Promise<string> {
     const configs = toArrayIfNot(_configs);
     const appTitle = this.appService.getAppTitle();
     const timeStamp = (new Date()).getTime();
@@ -372,10 +389,9 @@ class ExportService {
     return zipFile;
   }
 
-  getReadStreamFromRevision(revision, format) {
+  getReadStreamFromRevision(revision, format): Readable {
     const data = revision.body;
 
-    const Readable = require('stream').Readable;
     const readable = new Readable();
     readable._read = () => {};
     readable.push(data);
@@ -386,4 +402,8 @@ class ExportService {
 
 }
 
-module.exports = ExportService;
+// eslint-disable-next-line import/no-mutable-exports
+export let exportService: ExportService | undefined; // singleton instance
+export default function instanciate(crowi: any): void {
+  exportService = new ExportService(crowi);
+}

+ 1 - 1
apps/app/src/server/service/external-account.ts

@@ -71,6 +71,6 @@ class ExternalAccountService {
 
 // eslint-disable-next-line import/no-mutable-exports
 export let externalAccountService: ExternalAccountService | undefined; // singleton instance
-export function instanciate(passportService: PassportService): void {
+export default function instanciate(passportService: PassportService): void {
   externalAccountService = new ExternalAccountService(passportService);
 }

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است