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

Revert "support: Revert pdf converter implement"

Futa Arai 1 год назад
Родитель
Сommit
f975e96341
100 измененных файлов с 3275 добавлено и 362 удалено
  1. 7 4
      .devcontainer/app/devcontainer.json
  2. 0 0
      .devcontainer/app/postCreateCommand.sh
  3. 13 1
      .devcontainer/compose.yml
  4. 29 0
      .devcontainer/pdf-converter/devcontainer.json
  5. 17 0
      .devcontainer/pdf-converter/postCreateCommand.sh
  6. 168 0
      .github/workflows/ci-pdf-converter.yml
  7. 125 0
      .github/workflows/release-pdf-converter.yml
  8. 5 1
      apps/app/docker/docker-entrypoint.sh
  9. 5 0
      apps/app/package.json
  10. 9 2
      apps/app/public/static/locales/en_US/admin.json
  11. 17 2
      apps/app/public/static/locales/en_US/translation.json
  12. 9 2
      apps/app/public/static/locales/fr_FR/admin.json
  13. 17 2
      apps/app/public/static/locales/fr_FR/translation.json
  14. 9 2
      apps/app/public/static/locales/ja_JP/admin.json
  15. 17 2
      apps/app/public/static/locales/ja_JP/translation.json
  16. 9 2
      apps/app/public/static/locales/zh_CN/admin.json
  17. 17 2
      apps/app/public/static/locales/zh_CN/translation.json
  18. 8 0
      apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx
  19. 9 5
      apps/app/src/client/components/Admin/App/FileUploadSetting.tsx
  20. 136 0
      apps/app/src/client/components/Admin/App/PageBulkExportSettings.tsx
  21. 42 28
      apps/app/src/client/components/Admin/App/QuestionnaireSettings.tsx
  22. 26 17
      apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx
  23. 0 0
      apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.module.scss
  24. 13 6
      apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.tsx
  25. 69 0
      apps/app/src/client/components/InAppNotification/ModelNotification/PageBulkExportJobModelNotification.tsx
  26. 2 8
      apps/app/src/client/components/InAppNotification/ModelNotification/PageModelNotification.tsx
  27. 2 1
      apps/app/src/client/components/InAppNotification/ModelNotification/UserModelNotification.tsx
  28. 31 0
      apps/app/src/client/components/InAppNotification/ModelNotification/index.tsx
  29. 9 0
      apps/app/src/client/components/InAppNotification/ModelNotification/useActionAndMsg.ts
  30. 0 19
      apps/app/src/client/components/InAppNotification/PageNotification/index.tsx
  31. 19 3
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  32. 2 2
      apps/app/src/client/components/Navbar/PageEditorModeManager.tsx
  33. 1 1
      apps/app/src/client/components/SearchPage/SearchResultContent.tsx
  34. 0 1
      apps/app/src/client/services/AdminAppContainer.js
  35. 3 0
      apps/app/src/components/Layout/BasicLayout.tsx
  36. 113 0
      apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx
  37. 27 0
      apps/app/src/features/page-bulk-export/client/stores/modal.tsx
  38. 49 0
      apps/app/src/features/page-bulk-export/interfaces/page-bulk-export.ts
  39. 29 0
      apps/app/src/features/page-bulk-export/server/models/page-bulk-export-job.ts
  40. 19 0
      apps/app/src/features/page-bulk-export/server/models/page-bulk-export-page-snapshot.ts
  41. 58 0
      apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts
  42. 41 0
      apps/app/src/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron.ts
  43. 180 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.integ.ts
  44. 118 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.ts
  45. 15 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/errors.ts
  46. 285 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts
  47. 62 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts
  48. 70 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts
  49. 103 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/create-page-snapshots-async.ts
  50. 112 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/export-pages-to-fs-async.ts
  51. 78 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export.ts
  52. 28 28
      apps/app/src/features/questionnaire/server/service/questionnaire-cron.integ.ts
  53. 16 42
      apps/app/src/features/questionnaire/server/service/questionnaire-cron.ts
  54. 14 0
      apps/app/src/interfaces/activity.ts
  55. 28 0
      apps/app/src/interfaces/file-uploader.ts
  56. 5 0
      apps/app/src/interfaces/res/admin/app-settings.ts
  57. 25 0
      apps/app/src/models/serializers/in-app-notification-snapshot/page-bulk-export-job.ts
  58. 4 1
      apps/app/src/pages/[[...path]].page.tsx
  59. 17 14
      apps/app/src/server/crowi/index.js
  60. 7 0
      apps/app/src/server/interfaces/attachment.ts
  61. 2 1
      apps/app/src/server/models/activity.ts
  62. 6 2
      apps/app/src/server/models/attachment.ts
  63. 8 4
      apps/app/src/server/models/subscription.ts
  64. 0 13
      apps/app/src/server/models/vo/collection-progress.js
  65. 19 0
      apps/app/src/server/models/vo/collection-progress.ts
  66. 12 7
      apps/app/src/server/models/vo/collection-progressing-status.ts
  67. 3 5
      apps/app/src/server/routes/admin.js
  68. 36 1
      apps/app/src/server/routes/apiv3/app-settings.js
  69. 2 1
      apps/app/src/server/routes/apiv3/export.js
  70. 5 4
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  71. 1 0
      apps/app/src/server/routes/apiv3/index.js
  72. 5 1
      apps/app/src/server/routes/apiv3/page/index.ts
  73. 1 1
      apps/app/src/server/service/attachment.js
  74. 47 0
      apps/app/src/server/service/config-manager/config-definition.ts
  75. 65 0
      apps/app/src/server/service/cron.ts
  76. 58 38
      apps/app/src/server/service/export.ts
  77. 1 1
      apps/app/src/server/service/external-account.ts
  78. 44 13
      apps/app/src/server/service/file-uploader/aws/index.ts
  79. 104 0
      apps/app/src/server/service/file-uploader/aws/multipart-uploader.ts
  80. 6 6
      apps/app/src/server/service/file-uploader/azure.ts
  81. 21 3
      apps/app/src/server/service/file-uploader/file-uploader.ts
  82. 47 15
      apps/app/src/server/service/file-uploader/gcs/index.ts
  83. 122 0
      apps/app/src/server/service/file-uploader/gcs/multipart-uploader.ts
  84. 2 3
      apps/app/src/server/service/file-uploader/gridfs.ts
  85. 2 12
      apps/app/src/server/service/file-uploader/index.ts
  86. 5 6
      apps/app/src/server/service/file-uploader/local.ts
  87. 68 0
      apps/app/src/server/service/file-uploader/multipart-uploader.spec.ts
  88. 93 0
      apps/app/src/server/service/file-uploader/multipart-uploader.ts
  89. 5 2
      apps/app/src/server/service/g2g-transfer.ts
  90. 10 10
      apps/app/src/server/service/growi-bridge/index.ts
  91. 13 7
      apps/app/src/server/service/in-app-notification.ts
  92. 13 3
      apps/app/src/server/service/in-app-notification/in-app-notification-utils.ts
  93. 13 0
      apps/app/src/server/service/interfaces/export.ts
  94. 4 3
      apps/app/src/server/service/pre-notify.ts
  95. 33 0
      apps/app/src/server/util/stream.spec.ts
  96. 43 0
      apps/app/src/server/util/stream.ts
  97. 4 0
      apps/app/src/stores-universal/context.tsx
  98. 2 2
      apps/app/test/integration/service/external-user-group-sync.test.ts
  99. 1 0
      apps/pdf-converter/.env
  100. 1 0
      apps/pdf-converter/.eslintignore

+ 7 - 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",
+  "service": "app",
   "workspaceFolder": "/workspace/growi",
 
   "features": {
@@ -16,7 +16,7 @@
   // "forwardPorts": [],
 
   // 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 +37,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.

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


+ 13 - 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
@@ -44,10 +45,21 @@ services:
       - /usr/share/elasticsearch/data
       - ../../growi-docker-compose/elasticsearch/v8/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
 
+  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
+
 volumes:
   pnpm-store:
   node_modules:
   buildcache_app:
+  page_bulk_export_tmp:
 
 networks:
   default:

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

@@ -0,0 +1,29 @@
+{
+  "name": "GROWI-PDF-Converter",
+  "dockerComposeFile": "../compose.yml",
+  "service": "pdf-converter",
+  "workspaceFolder": "/workspace/growi",
+
+  // Use 'forwardPorts' to make a list of ports inside the container available locally.
+  // "forwardPorts": [],
+
+  // 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"
+}

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

@@ -0,0 +1,17 @@
+# 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
+
+# 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

+ 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 }}

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

@@ -0,0 +1,125 @@
+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@v4
+      with:
+        context: .
+        file: ./apps/pdf-converter/docker/Dockerfile
+        platforms: linux/amd64
+        push: true
+        builder: ${{ steps.buildx.outputs.name }}
+        cache-from: type=gha
+        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:
+        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        CUSTOM_TAG: v${{ steps.package-json.outputs.packageVersion }}
+        VERBOSE : true
+
+    - name: Update Docker Hub Description
+      uses: peter-evans/dockerhub-description@v3
+      with:
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+        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 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 }}

+ 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",
@@ -1060,7 +1066,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

@@ -657,11 +657,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",
@@ -1059,7 +1065,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

@@ -651,11 +651,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": "この設定を途中で変更すると、これまでにアップロードしたファイル等へのアクセスができなくなりますのでご注意下さい。",
@@ -1070,7 +1076,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

@@ -689,11 +689,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": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
@@ -1069,7 +1075,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

@@ -660,11 +660,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": "连接成功!",

+ 8 - 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,13 @@ const AppSettingsPageContents = (props: Props) => {
         </div>
       </div>
 
+      <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>
   );
 };

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

@@ -157,7 +157,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

@@ -34,6 +34,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 AiChatModal = dynamic(() => import('~/features/openai/chat/components/AiChatModal').then(mod => mod.AiChatModal), { ssr: false });
 
 type Props = {
@@ -73,6 +75,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
       <HotkeysManager />
 
       <ShortcutsModal />
+      <PageBulkExportSelectModal />
       <GrantedGroupsInheritanceSelectModal />
       <SystemVersion showShortcutsButton />
     </RawLayout>

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

@@ -0,0 +1,113 @@
+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 { useCurrentPagePath } from '~/stores/page';
+
+const PageBulkExportSelectModal = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: status, close } = usePageBulkExportSelectModal();
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  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>
+              <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;
+
+};

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

@@ -0,0 +1,41 @@
+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> {
+    const isBulkExportPagesEnabled = configManager.getConfig('app:isBulkExportPagesEnabled');
+    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:pageBulkExportPdfConverterUrl') });
+
+    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: currentUser,
+      page: basePage,
+      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';
@@ -51,6 +52,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';
@@ -75,6 +79,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';
@@ -166,6 +171,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 = {
@@ -254,6 +260,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,
@@ -340,6 +347,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
@@ -358,6 +368,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 = {
@@ -442,6 +455,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,
 }

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

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

@@ -42,7 +42,7 @@ import {
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useIsEnabledMarp, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useGrowiCloudUri,
   useIsAllReplyShown, useIsContainerFluid, useIsNotCreatable,
-  useIsUploadAllFileAllowed, useIsUploadEnabled,
+  useIsUploadAllFileAllowed, useIsUploadEnabled, useIsBulkExportPagesEnabled,
   useElasticsearchMaxBodyLengthToIndex,
   useIsLocalAccountRegistrationEnabled,
   useIsRomUserAllowedToComment,
@@ -180,6 +180,7 @@ type Props = CommonProps & {
   isContainerFluid: boolean,
   isUploadEnabled: boolean,
   isUploadAllFileAllowed: boolean,
+  isBulkExportPagesEnabled: boolean,
   isEnabledStaleNotification: boolean,
   isEnabledAttachTitleHeader: boolean,
   // isEnabledLinebreaks: boolean,
@@ -243,6 +244,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
   useIsUploadAllFileAllowed(props.isUploadAllFileAllowed);
   useIsUploadEnabled(props.isUploadEnabled);
+  useIsBulkExportPagesEnabled(props.isBulkExportPagesEnabled);
 
   useIsLocalAccountRegistrationEnabled(props.isLocalAccountRegistrationEnabled);
   useIsRomUserAllowedToComment(props.isRomUserAllowedToComment);
@@ -586,6 +588,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.disableLinkSharing = configManager.getConfig('security:disableLinkSharing');
   props.isUploadAllFileAllowed = fileUploadService.getFileUploadEnabled();
   props.isUploadEnabled = fileUploadService.getIsUploadable();
+  props.isBulkExportPagesEnabled = configManager.getConfig('app:isBulkExportPagesEnabled');
 
   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);
 

+ 36 - 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: crowi.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,15 @@ module.exports = (crowi) => {
       isAppSiteUrlHashed: configManager.getConfig('questionnaire:isAppSiteUrlHashed'),
 
       isMaintenanceMode: configManager.getConfig('app:isMaintenanceMode'),
+
+      isBulkExportPagesEnabled: crowi.configManager.getConfig('app:isBulkExportPagesEnabled'),
+      envIsBulkExportPagesEnabled: crowi.configManager.getConfig('app:isBulkExportPagesEnabled'),
+      bulkExportDownloadExpirationSeconds: crowi.configManager.getConfig('app:bulkExportDownloadExpirationSeconds'),
     };
     return res.apiv3({ appSettingsParams });
 
   });
 
-
   /**
    * @swagger
    *
@@ -1020,6 +1028,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 crowi.configManager.updateConfigs(requestParams, { skipPubsub: true });
+        const responseParams = {
+          isBulkExportPagesEnabled: crowi.configManager.getConfig('app:isBulkExportPagesEnabled'),
+          bulkExportDownloadExpirationSeconds: crowi.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

@@ -124,6 +124,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';
@@ -118,7 +119,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');
 
@@ -761,6 +762,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) {

+ 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

@@ -317,6 +317,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:pageBulkExportPdfConverterUrl',
+  'app:isBulkExportPagesEnabled',
+  'env:useOnlyEnvVars:app:isBulkExportPagesEnabled',
+
 ] as const;
 
 
@@ -1280,6 +1291,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:pageBulkExportPdfConverterUrl': defineConfig<string>({
+    envVarName: 'BULK_EXPORT_PDF_CONVERTER_URL',
+    defaultValue: 'http://pdf-converter:3010',
+  }),
+  '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);
 }

+ 44 - 13
apps/app/src/server/service/file-uploader/aws.ts → apps/app/src/server/service/file-uploader/aws/index.ts

@@ -1,4 +1,4 @@
-import type { ReadStream } from 'fs';
+import type { Readable } from 'stream';
 
 import type { GetObjectCommandInput, HeadObjectCommandInput } from '@aws-sdk/client-s3';
 import {
@@ -10,21 +10,25 @@ import {
   DeleteObjectCommand,
   ListObjectsCommand,
   ObjectCannedACL,
+  AbortMultipartUploadCommand,
 } from '@aws-sdk/client-s3';
 import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 import urljoin from 'url-join';
 
 import type Crowi from '~/server/crowi';
-import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
+import {
+  AttachmentType, FilePathOnStoragePrefix, ResponseMode, type RespondOptions,
+} from '~/server/interfaces/attachment';
 import type { IAttachmentDocument } from '~/server/models/attachment';
 import loggerFactory from '~/utils/logger';
 
-import { configManager } from '../config-manager';
-
+import { configManager } from '../../config-manager';
 import {
   AbstractFileUploader, type TemporaryUrl, type SaveFileParam,
-} from './file-uploader';
-import { ContentHeaders } from './utils';
+} from '../file-uploader';
+import { ContentHeaders } from '../utils';
+
+import { AwsMultipartUploader } from './multipart-uploader';
 
 
 const logger = loggerFactory('growi:service:fileUploaderAws');
@@ -96,20 +100,26 @@ const S3Factory = (): S3Client => {
   });
 };
 
-const getFilePathOnStorage = (attachment) => {
+const getFilePathOnStorage = (attachment: IAttachmentDocument) => {
   if (attachment.filePath != null) { // DEPRECATED: remains for backward compatibility for v3.3.x or below
     return attachment.filePath;
   }
 
-  const dirName = (attachment.page != null)
-    ? 'attachment'
-    : 'user';
+  let dirName: string;
+  if (attachment.attachmentType === AttachmentType.PAGE_BULK_EXPORT) {
+    dirName = FilePathOnStoragePrefix.pageBulkExport;
+  }
+  else if (attachment.page != null) {
+    dirName = FilePathOnStoragePrefix.attachment;
+  }
+  else {
+    dirName = FilePathOnStoragePrefix.user;
+  }
   const filePath = urljoin(dirName, attachment.fileName);
 
   return filePath;
 };
 
-
 // TODO: rewrite this module to be a type-safe implementation
 class AwsFileUploader extends AbstractFileUploader {
 
@@ -153,7 +163,7 @@ class AwsFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override async uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void> {
+  override async uploadAttachment(readable: Readable, attachment: IAttachmentDocument): Promise<void> {
     if (!this.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -168,7 +178,7 @@ class AwsFileUploader extends AbstractFileUploader {
     await s3.send(new PutObjectCommand({
       Bucket: getS3Bucket(),
       Key: filePath,
-      Body: readStream,
+      Body: readable,
       ACL: getS3PutObjectCannedAcl(),
       // put type and the file name for reference information when uploading
       ContentType: contentHeaders.contentType?.value.toString(),
@@ -256,6 +266,27 @@ class AwsFileUploader extends AbstractFileUploader {
 
   }
 
+  override createMultipartUploader(uploadKey: string, maxPartSize: number) {
+    const s3 = S3Factory();
+    return new AwsMultipartUploader(s3, getS3Bucket(), uploadKey, maxPartSize);
+  }
+
+  override async abortPreviousMultipartUpload(uploadKey: string, uploadId: string) {
+    try {
+      await S3Factory().send(new AbortMultipartUploadCommand({
+        Bucket: getS3Bucket(),
+        Key: uploadKey,
+        UploadId: uploadId,
+      }));
+    }
+    catch (e) {
+      // allow duplicate abort requests to ensure abortion
+      if (e.response?.status !== 404) {
+        throw e;
+      }
+    }
+  }
+
 }
 
 module.exports = (crowi: Crowi) => {

+ 104 - 0
apps/app/src/server/service/file-uploader/aws/multipart-uploader.ts

@@ -0,0 +1,104 @@
+import {
+  CreateMultipartUploadCommand, UploadPartCommand, type S3Client, CompleteMultipartUploadCommand, AbortMultipartUploadCommand,
+  HeadObjectCommand,
+} from '@aws-sdk/client-s3';
+
+import loggerFactory from '~/utils/logger';
+
+import { MultipartUploader, UploadStatus, type IMultipartUploader } from '../multipart-uploader';
+
+
+const logger = loggerFactory('growi:services:fileUploaderAws:multipartUploader');
+
+export type IAwsMultipartUploader = IMultipartUploader
+
+export class AwsMultipartUploader extends MultipartUploader implements IAwsMultipartUploader {
+
+  private bucket: string | undefined;
+
+  private s3Client: S3Client;
+
+  private parts: { PartNumber: number; ETag: string | undefined; }[] = [];
+
+  constructor(s3Client: S3Client, bucket: string | undefined, uploadKey: string, maxPartSize: number) {
+    super(uploadKey, maxPartSize);
+
+    this.s3Client = s3Client;
+    this.bucket = bucket;
+    this.uploadKey = uploadKey;
+  }
+
+  async initUpload(): Promise<void> {
+    this.validateUploadStatus(UploadStatus.BEFORE_INIT);
+
+    const response = await this.s3Client.send(new CreateMultipartUploadCommand({
+      Bucket: this.bucket,
+      Key: this.uploadKey,
+    }));
+    if (response.UploadId == null) {
+      throw Error('UploadId is empty');
+    }
+    this._uploadId = response.UploadId;
+    this.currentStatus = UploadStatus.IN_PROGRESS;
+    logger.info(`Multipart upload initialized. Upload key: ${this.uploadKey}`);
+  }
+
+  async uploadPart(part: Buffer, partNumber: number): Promise<void> {
+    this.validateUploadStatus(UploadStatus.IN_PROGRESS);
+    this.validatePartSize(part.length);
+
+    const uploadMetaData = await this.s3Client.send(new UploadPartCommand({
+      Body: part,
+      Bucket: this.bucket,
+      Key: this.uploadKey,
+      PartNumber: partNumber,
+      UploadId: this.uploadId,
+    }));
+
+    this.parts.push({
+      PartNumber: partNumber,
+      ETag: uploadMetaData.ETag,
+    });
+    this._uploadedFileSize += part.length;
+  }
+
+  async completeUpload(): Promise<void> {
+    this.validateUploadStatus(UploadStatus.IN_PROGRESS);
+
+    await this.s3Client.send(new CompleteMultipartUploadCommand({
+      Bucket: this.bucket,
+      Key: this.uploadKey,
+      UploadId: this.uploadId,
+      MultipartUpload: {
+        Parts: this.parts,
+      },
+    }));
+    this.currentStatus = UploadStatus.COMPLETED;
+    logger.info(`Multipart upload completed. Upload key: ${this.uploadKey}`);
+  }
+
+  async abortUpload(): Promise<void> {
+    this.validateUploadStatus(UploadStatus.IN_PROGRESS);
+
+    await this.s3Client.send(new AbortMultipartUploadCommand({
+      Bucket: this.bucket,
+      Key: this.uploadKey,
+      UploadId: this.uploadId,
+    }));
+    this.currentStatus = UploadStatus.ABORTED;
+    logger.info(`Multipart upload aborted. Upload key: ${this.uploadKey}`);
+  }
+
+  async getUploadedFileSize(): Promise<number> {
+    if (this.currentStatus === UploadStatus.COMPLETED) {
+      const headData = await this.s3Client.send(new HeadObjectCommand({
+        Bucket: this.bucket,
+        Key: this.uploadKey,
+      }));
+      if (headData.ContentLength == null) throw Error('Could not fetch uploaded file size');
+      this._uploadedFileSize = headData.ContentLength;
+    }
+    return this._uploadedFileSize;
+  }
+
+}

+ 6 - 6
apps/app/src/server/service/file-uploader/azure.ts

@@ -1,4 +1,4 @@
-import type { ReadStream } from 'fs';
+import type { Readable } from 'stream';
 
 import type { TokenCredential } from '@azure/identity';
 import { ClientSecretCredential } from '@azure/identity';
@@ -19,7 +19,7 @@ import {
 } from '@azure/storage-blob';
 
 import type Crowi from '~/server/crowi';
-import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
+import { FilePathOnStoragePrefix, ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
 import type { IAttachmentDocument } from '~/server/models/attachment';
 import loggerFactory from '~/utils/logger';
 
@@ -77,8 +77,8 @@ async function getContainerClient(): Promise<ContainerClient> {
   return blobServiceClient.getContainerClient(containerName);
 }
 
-function getFilePathOnStorage(attachment) {
-  const dirName = (attachment.page != null) ? 'attachment' : 'user';
+function getFilePathOnStorage(attachment: IAttachmentDocument) {
+  const dirName = (attachment.page != null) ? FilePathOnStoragePrefix.attachment : FilePathOnStoragePrefix.user;
   return urljoin(dirName, attachment.fileName);
 }
 
@@ -122,7 +122,7 @@ class AzureFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override async uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void> {
+  override async uploadAttachment(readable: Readable, attachment: IAttachmentDocument): Promise<void> {
     if (!this.getIsUploadable()) {
       throw new Error('Azure is not configured.');
     }
@@ -133,7 +133,7 @@ class AzureFileUploader extends AbstractFileUploader {
     const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
     const contentHeaders = new ContentHeaders(attachment);
 
-    await blockBlobClient.uploadStream(readStream, undefined, undefined, {
+    await blockBlobClient.uploadStream(readable, undefined, undefined, {
       blobHTTPHeaders: {
         // put type and the file name for reference information when uploading
         blobContentType: contentHeaders.contentType?.value.toString(),

+ 21 - 3
apps/app/src/server/service/file-uploader/file-uploader.ts

@@ -1,4 +1,4 @@
-import type { ReadStream } from 'fs';
+import type { Readable } from 'stream';
 
 import type { Response } from 'express';
 import { v4 as uuidv4 } from 'uuid';
@@ -11,6 +11,8 @@ import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';
 
+import type { MultipartUploader } from './multipart-uploader';
+
 const logger = loggerFactory('growi:service:fileUploader');
 
 
@@ -38,10 +40,12 @@ export interface FileUploader {
   getTotalFileSize(): Promise<number>,
   doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<ICheckLimitResult>,
   determineResponseMode(): ResponseMode,
-  uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void>,
+  uploadAttachment(readable: Readable, attachment: IAttachmentDocument): Promise<void>,
   respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void,
   findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream>,
   generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl>,
+  createMultipartUploader: (uploadKey: string, maxPartSize: number) => MultipartUploader,
+  abortPreviousMultipartUpload: (uploadKey: string, uploadId: string) => Promise<void>
 }
 
 export abstract class AbstractFileUploader implements FileUploader {
@@ -154,7 +158,21 @@ export abstract class AbstractFileUploader implements FileUploader {
     return ResponseMode.RELAY;
   }
 
- abstract uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void>;
+  /**
+   * Create a multipart uploader for cloud storage
+   */
+  createMultipartUploader(uploadKey: string, maxPartSize: number): MultipartUploader {
+    throw new Error('Multipart upload not available for file upload type');
+  }
+
+  abstract uploadAttachment(readable: Readable, attachment: IAttachmentDocument): Promise<void>;
+
+  /**
+   * Abort an existing multipart upload without creating a MultipartUploader instance
+   */
+  abortPreviousMultipartUpload(uploadKey: string, uploadId: string): Promise<void> {
+    throw new Error('Multipart upload not available for file upload type');
+  }
 
   /**
    * Respond to the HTTP request.

+ 47 - 15
apps/app/src/server/service/file-uploader/gcs.ts → apps/app/src/server/service/file-uploader/gcs/index.ts

@@ -1,19 +1,24 @@
-import type { ReadStream } from 'fs';
+import type { Readable } from 'stream';
+import { pipeline } from 'stream/promises';
 
 import { Storage } from '@google-cloud/storage';
+import axios from 'axios';
 import urljoin from 'url-join';
 
 import type Crowi from '~/server/crowi';
-import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
+import {
+  AttachmentType, FilePathOnStoragePrefix, ResponseMode, type RespondOptions,
+} from '~/server/interfaces/attachment';
 import type { IAttachmentDocument } from '~/server/models/attachment';
 import loggerFactory from '~/utils/logger';
 
-import { configManager } from '../config-manager';
-
+import { configManager } from '../../config-manager';
 import {
   AbstractFileUploader, type TemporaryUrl, type SaveFileParam,
-} from './file-uploader';
-import { ContentHeaders } from './utils';
+} from '../file-uploader';
+import { ContentHeaders } from '../utils';
+
+import { GcsMultipartUploader } from './multipart-uploader';
 
 const logger = loggerFactory('growi:service:fileUploaderGcs');
 
@@ -38,11 +43,18 @@ function getGcsInstance() {
   return storage;
 }
 
-function getFilePathOnStorage(attachment) {
+function getFilePathOnStorage(attachment: IAttachmentDocument) {
   const namespace = configManager.getConfig('gcs:uploadNamespace');
-  const dirName = (attachment.page != null)
-    ? 'attachment'
-    : 'user';
+  let dirName: string;
+  if (attachment.attachmentType === AttachmentType.PAGE_BULK_EXPORT) {
+    dirName = FilePathOnStoragePrefix.pageBulkExport;
+  }
+  else if (attachment.page != null) {
+    dirName = FilePathOnStoragePrefix.attachment;
+  }
+  else {
+    dirName = FilePathOnStoragePrefix.user;
+  }
   const filePath = urljoin(namespace, dirName, attachment.fileName);
 
   return filePath;
@@ -58,7 +70,6 @@ async function isFileExists(file) {
   return res[0];
 }
 
-
 // TODO: rewrite this module to be a type-safe implementation
 class GcsFileUploader extends AbstractFileUploader {
 
@@ -109,7 +120,7 @@ class GcsFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override async uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void> {
+  override async uploadAttachment(readable: Readable, attachment: IAttachmentDocument): Promise<void> {
     if (!this.getIsUploadable()) {
       throw new Error('GCS is not configured.');
     }
@@ -121,11 +132,12 @@ class GcsFileUploader extends AbstractFileUploader {
     const filePath = getFilePathOnStorage(attachment);
     const contentHeaders = new ContentHeaders(attachment);
 
-    await myBucket.upload(readStream.path.toString(), {
-      destination: filePath,
+    const file = myBucket.file(filePath);
+
+    await pipeline(readable, file.createWriteStream({
       // put type and the file name for reference information when uploading
       contentType: contentHeaders.contentType?.value.toString(),
-    });
+    }));
   }
 
   /**
@@ -195,6 +207,26 @@ class GcsFileUploader extends AbstractFileUploader {
 
   }
 
+  override createMultipartUploader(uploadKey: string, maxPartSize: number) {
+    const gcs = getGcsInstance();
+    const myBucket = gcs.bucket(getGcsBucket());
+    return new GcsMultipartUploader(myBucket, uploadKey, maxPartSize);
+  }
+
+  override async abortPreviousMultipartUpload(uploadKey: string, uploadId: string) {
+    try {
+      await axios.delete(uploadId);
+    }
+    catch (e) {
+      // allow 404: allow duplicate abort requests to ensure abortion
+      // allow 499: it is the success response code for canceling upload
+      // ref: https://cloud.google.com/storage/docs/performing-resumable-uploads#cancel-upload
+      if (e.response?.status !== 404 && e.response?.status !== 499) {
+        throw e;
+      }
+    }
+  }
+
 }
 
 

+ 122 - 0
apps/app/src/server/service/file-uploader/gcs/multipart-uploader.ts

@@ -0,0 +1,122 @@
+import type { Bucket, File } from '@google-cloud/storage';
+// eslint-disable-next-line no-restricted-imports
+import axios from 'axios';
+import urljoin from 'url-join';
+
+import loggerFactory from '~/utils/logger';
+
+import { configManager } from '../../config-manager';
+import { MultipartUploader, UploadStatus, type IMultipartUploader } from '../multipart-uploader';
+
+const logger = loggerFactory('growi:services:fileUploaderGcs:multipartUploader');
+
+export type IGcsMultipartUploader = IMultipartUploader
+
+export class GcsMultipartUploader extends MultipartUploader implements IGcsMultipartUploader {
+
+  private file: File;
+
+  // ref: https://cloud.google.com/storage/docs/performing-resumable-uploads?hl=en#chunked-upload
+  private readonly minPartSize = 256 * 1024; // 256KB
+
+  constructor(bucket: Bucket, uploadKey: string, maxPartSize: number) {
+    super(uploadKey, maxPartSize);
+
+    const namespace = configManager.getConfig('gcs:uploadNamespace');
+    this.file = bucket.file(urljoin(namespace, uploadKey));
+  }
+
+  async initUpload(): Promise<void> {
+    this.validateUploadStatus(UploadStatus.BEFORE_INIT);
+
+    const [uploadUrl] = await this.file.createResumableUpload();
+
+    this._uploadId = uploadUrl;
+    this.currentStatus = UploadStatus.IN_PROGRESS;
+    logger.info(`Multipart upload initialized. Upload key: ${this.uploadKey}`);
+  }
+
+  async uploadPart(part: Buffer, partNumber: number): Promise<void> {
+    this.validateUploadStatus(UploadStatus.IN_PROGRESS);
+    this.validatePartSize(part.length);
+
+    // Upload the whole part in one request, or divide it in chunks and upload depending on the part size
+    if (part.length === this.maxPartSize) {
+      await this.uploadChunk(part);
+    }
+    else if (this.minPartSize < part.length && part.length < this.maxPartSize) {
+      const numOfMinPartSize = Math.floor(part.length / this.minPartSize);
+      const minPartSizeMultiplePartChunk = part.slice(0, numOfMinPartSize * this.minPartSize);
+      const lastPartChunk = part.slice(numOfMinPartSize * this.minPartSize);
+
+      await this.uploadChunk(minPartSizeMultiplePartChunk);
+      await this.uploadChunk(lastPartChunk, true);
+    }
+    else if (part.length < this.minPartSize) {
+      await this.uploadChunk(part, true);
+    }
+  }
+
+  async completeUpload(): Promise<void> {
+    this.validateUploadStatus(UploadStatus.IN_PROGRESS);
+
+    // Send a request to complete the upload, in case the last uploadPart request did not request completion.
+    await axios.put(this.uploadId, {
+      headers: {
+        'Content-Range': `bytes */${this._uploadedFileSize}`,
+      },
+    });
+    this.currentStatus = UploadStatus.COMPLETED;
+    logger.info(`Multipart upload completed. Upload key: ${this.uploadKey}`);
+  }
+
+  async abortUpload(): Promise<void> {
+    this.validateUploadStatus(UploadStatus.IN_PROGRESS);
+
+    try {
+      await axios.delete(this.uploadId);
+    }
+    catch (e) {
+      // 499 is successful response code for canceling upload
+      // ref: https://cloud.google.com/storage/docs/performing-resumable-uploads#cancel-upload
+      if (e.response?.status !== 499) {
+        throw e;
+      }
+    }
+    this.currentStatus = UploadStatus.ABORTED;
+    logger.info(`Multipart upload aborted. Upload key: ${this.uploadKey}`);
+  }
+
+  async getUploadedFileSize(): Promise<number> {
+    if (this.currentStatus === UploadStatus.COMPLETED) {
+      const [metadata] = await this.file.getMetadata();
+      this._uploadedFileSize = metadata.size;
+    }
+    return this._uploadedFileSize;
+  }
+
+  private uploadChunk = async(chunk, isLastUpload = false) => {
+    // If chunk size is larger than the minimal part size, it is required to be a multiple of the minimal part size
+    // ref: https://cloud.google.com/storage/docs/performing-resumable-uploads?hl=en#chunked-upload
+    if (chunk.length > this.minPartSize && chunk.length % this.minPartSize !== 0) throw Error(`chunk must be a multiple of ${this.minPartSize}`);
+
+    const range = isLastUpload
+      ? `bytes ${this._uploadedFileSize}-${this._uploadedFileSize + chunk.length - 1}/${this._uploadedFileSize + chunk.length}`
+      : `bytes ${this._uploadedFileSize}-${this._uploadedFileSize + chunk.length - 1}/*`;
+
+    try {
+      await axios.put(this.uploadId, chunk, {
+        headers: {
+          'Content-Range': `${range}`,
+        },
+      });
+    }
+    catch (e) {
+      if (e.response?.status !== 308) {
+        throw e;
+      }
+    }
+    this._uploadedFileSize += chunk.length;
+  };
+
+}

+ 2 - 3
apps/app/src/server/service/file-uploader/gridfs.ts

@@ -1,4 +1,3 @@
-import type { ReadStream } from 'fs';
 import { Readable } from 'stream';
 import util from 'util';
 
@@ -63,7 +62,7 @@ class GridfsFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override async uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void> {
+  override async uploadAttachment(readable: Readable, attachment: IAttachmentDocument): Promise<void> {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
     const contentHeaders = new ContentHeaders(attachment);
@@ -74,7 +73,7 @@ class GridfsFileUploader extends AbstractFileUploader {
         filename: attachment.fileName,
         contentType: contentHeaders.contentType?.value.toString(),
       },
-      readStream,
+      readable,
     );
   }
 

+ 2 - 12
apps/app/src/server/service/file-uploader/index.ts

@@ -1,3 +1,4 @@
+import { EnvToModuleMappings } from '~/interfaces/file-uploader';
 import type Crowi from '~/server/crowi';
 import loggerFactory from '~/utils/logger';
 
@@ -9,19 +10,8 @@ export type { FileUploader } from './file-uploader';
 
 const logger = loggerFactory('growi:service:FileUploaderServise');
 
-const envToModuleMappings = {
-  aws:     'aws',
-  local:   'local',
-  mongo:   'gridfs',
-  mongodb: 'gridfs',
-  gridfs:  'gridfs',
-  gcp:     'gcs',
-  gcs:     'gcs',
-  azure:   'azure',
-};
-
 export const getUploader = (crowi: Crowi): FileUploader => {
-  const method = envToModuleMappings[configManager.getConfig('app:fileUploadType')];
+  const method = EnvToModuleMappings[configManager.getConfig('app:fileUploadType')];
   const modulePath = `./${method}`;
   const uploader = require(modulePath)(crowi);
 

+ 5 - 6
apps/app/src/server/service/file-uploader/local.ts

@@ -1,4 +1,3 @@
-import type { ReadStream } from 'fs';
 import type { Writable } from 'stream';
 import { Readable } from 'stream';
 import { pipeline } from 'stream/promises';
@@ -6,7 +5,7 @@ import { pipeline } from 'stream/promises';
 import type { Response } from 'express';
 
 import type Crowi from '~/server/crowi';
-import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
+import { FilePathOnStoragePrefix, ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
 import type { IAttachmentDocument } from '~/server/models/attachment';
 import loggerFactory from '~/utils/logger';
 
@@ -77,7 +76,7 @@ class LocalFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override async uploadAttachment(readStream: ReadStream, attachment: IAttachmentDocument): Promise<void> {
+  override async uploadAttachment(readable: Readable, attachment: IAttachmentDocument): Promise<void> {
     throw new Error('Method not implemented.');
   }
 
@@ -109,10 +108,10 @@ module.exports = function(crowi: Crowi) {
 
   const basePath = path.posix.join(crowi.publicDir, 'uploads');
 
-  function getFilePathOnStorage(attachment) {
+  function getFilePathOnStorage(attachment: IAttachmentDocument) {
     const dirName = (attachment.page != null)
-      ? 'attachment'
-      : 'user';
+      ? FilePathOnStoragePrefix.attachment
+      : FilePathOnStoragePrefix.user;
     const filePath = path.posix.join(basePath, dirName, attachment.fileName);
 
     return filePath;

+ 68 - 0
apps/app/src/server/service/file-uploader/multipart-uploader.spec.ts

@@ -0,0 +1,68 @@
+import { UploadStatus, MultipartUploader } from './multipart-uploader';
+
+class MockMultipartUploader extends MultipartUploader {
+
+  async initUpload(): Promise<void> { return }
+
+  async uploadPart(part: Buffer, partNumber: number): Promise<void> { return }
+
+  async completeUpload(): Promise<void> { return }
+
+  async abortUpload(): Promise<void> { return }
+
+  async getUploadedFileSize(): Promise<number> { return 0 }
+
+  // Expose the protected method for testing
+  testValidateUploadStatus(desired: UploadStatus): void {
+    this.validateUploadStatus(desired);
+  }
+
+  setCurrentStatus(status: UploadStatus): void {
+    this.currentStatus = status;
+  }
+
+}
+
+describe('MultipartUploader', () => {
+  let uploader: MockMultipartUploader;
+
+  beforeEach(() => {
+    uploader = new MockMultipartUploader('test-upload-key', 10485760);
+  });
+
+  describe('validateUploadStatus', () => {
+    describe('When current status is equal to desired status', () => {
+      it('should not throw error', () => {
+        uploader.setCurrentStatus(UploadStatus.ABORTED);
+        expect(() => uploader.testValidateUploadStatus(UploadStatus.ABORTED)).not.toThrow();
+      });
+    });
+
+    describe('When current status is not equal to desired status', () => {
+      const cases = [
+        { current: UploadStatus.BEFORE_INIT, desired: UploadStatus.IN_PROGRESS, errorMessage: 'Multipart upload has not been initiated' },
+        { current: UploadStatus.BEFORE_INIT, desired: UploadStatus.COMPLETED, errorMessage: 'Multipart upload has not been initiated' },
+        { current: UploadStatus.BEFORE_INIT, desired: UploadStatus.ABORTED, errorMessage: 'Multipart upload has not been initiated' },
+        { current: UploadStatus.IN_PROGRESS, desired: UploadStatus.BEFORE_INIT, errorMessage: 'Multipart upload is already in progress' },
+        { current: UploadStatus.IN_PROGRESS, desired: UploadStatus.COMPLETED, errorMessage: 'Multipart upload is still in progress' },
+        { current: UploadStatus.IN_PROGRESS, desired: UploadStatus.ABORTED, errorMessage: 'Multipart upload is still in progress' },
+        { current: UploadStatus.COMPLETED, desired: UploadStatus.BEFORE_INIT, errorMessage: 'Multipart upload has already been completed' },
+        { current: UploadStatus.COMPLETED, desired: UploadStatus.IN_PROGRESS, errorMessage: 'Multipart upload has already been completed' },
+        { current: UploadStatus.COMPLETED, desired: UploadStatus.ABORTED, errorMessage: 'Multipart upload has already been completed' },
+        { current: UploadStatus.ABORTED, desired: UploadStatus.BEFORE_INIT, errorMessage: 'Multipart upload has been aborted' },
+        { current: UploadStatus.ABORTED, desired: UploadStatus.IN_PROGRESS, errorMessage: 'Multipart upload has been aborted' },
+        { current: UploadStatus.ABORTED, desired: UploadStatus.COMPLETED, errorMessage: 'Multipart upload has been aborted' },
+      ];
+
+      describe.each(cases)('When current status is $current and desired status is $desired', ({ current, desired, errorMessage }) => {
+        beforeEach(() => {
+          uploader.setCurrentStatus(current);
+        });
+
+        it(`should throw expected error: "${errorMessage}"`, () => {
+          expect(() => uploader.testValidateUploadStatus(desired)).toThrow(errorMessage);
+        });
+      });
+    });
+  });
+});

+ 93 - 0
apps/app/src/server/service/file-uploader/multipart-uploader.ts

@@ -0,0 +1,93 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:services:fileUploader:multipartUploader');
+
+export enum UploadStatus {
+  BEFORE_INIT,
+  IN_PROGRESS,
+  COMPLETED,
+  ABORTED
+}
+
+export interface IMultipartUploader {
+  initUpload(): Promise<void>;
+  uploadPart(body: Buffer, partNumber: number): Promise<void>;
+  completeUpload(): Promise<void>;
+  abortUpload(): Promise<void>;
+  uploadId: string;
+  getUploadedFileSize(): Promise<number>;
+}
+
+/**
+ * Abstract class for uploading files to cloud storage using multipart upload.
+ * Each instance is equivalent to a single multipart upload, and cannot be reused once completed.
+ */
+export abstract class MultipartUploader implements IMultipartUploader {
+
+  protected uploadKey: string;
+
+  protected _uploadId: string | undefined;
+
+  protected currentStatus: UploadStatus = UploadStatus.BEFORE_INIT;
+
+  protected _uploadedFileSize = 0;
+
+  protected readonly maxPartSize: number;
+
+  constructor(uploadKey: string, maxPartSize: number) {
+    this.maxPartSize = maxPartSize;
+    this.uploadKey = uploadKey;
+  }
+
+  get uploadId(): string {
+    if (this._uploadId == null) throw Error('UploadId is empty');
+    return this._uploadId;
+  }
+
+  abstract initUpload(): Promise<void>
+
+  abstract uploadPart(part: Buffer, partNumber: number): Promise<void>
+
+  abstract completeUpload(): Promise<void>
+
+  abstract abortUpload(): Promise<void>
+
+  abstract getUploadedFileSize(): Promise<number>
+
+  protected validatePartSize(partSize: number): void {
+    if (partSize > this.maxPartSize) throw Error(`partSize must be less than or equal to ${this.maxPartSize}`);
+  }
+
+  protected validateUploadStatus(desiredStatus: UploadStatus): void {
+    if (desiredStatus === this.currentStatus) return;
+
+    let errMsg: string | null = null;
+
+    if (this.currentStatus === UploadStatus.COMPLETED) {
+      errMsg = 'Multipart upload has already been completed';
+    }
+
+    if (this.currentStatus === UploadStatus.ABORTED) {
+      errMsg = 'Multipart upload has been aborted';
+    }
+
+    if (this.currentStatus === UploadStatus.IN_PROGRESS) {
+      if (desiredStatus === UploadStatus.BEFORE_INIT) {
+        errMsg = 'Multipart upload is already in progress';
+      }
+      else {
+        errMsg = 'Multipart upload is still in progress';
+      }
+    }
+
+    if (this.currentStatus === UploadStatus.BEFORE_INIT) {
+      errMsg = 'Multipart upload has not been initiated';
+    }
+
+    if (errMsg != null) {
+      logger.error(errMsg);
+      throw Error(errMsg);
+    }
+  }
+
+}

+ 5 - 2
apps/app/src/server/service/g2g-transfer.ts

@@ -26,6 +26,7 @@ import { Attachment } from '../models/attachment';
 import { G2GTransferError, G2GTransferErrorCode } from '../models/vo/g2g-transfer-error';
 
 import { configManager } from './config-manager';
+import { exportService } from './export';
 import type { ConfigKey } from './config-manager/config-definition';
 import { generateOverwriteParams } from './import/overwrite-params';
 
@@ -444,8 +445,10 @@ export class G2GTransferPusherService implements Pusher {
 
     let zipFileStream: ReadStream;
     try {
-      const zipFileStat = await this.crowi.exportService.export(collections);
-      const zipFilePath = zipFileStat.zipFilePath;
+      const zipFileStat = await exportService?.export(collections);
+      const zipFilePath = zipFileStat?.zipFilePath;
+
+      if (zipFilePath == null) throw new Error('Failed to generate zip file');
 
       zipFileStream = createReadStream(zipFilePath);
     }

+ 10 - 10
apps/app/src/server/service/growi-bridge/index.ts

@@ -8,6 +8,8 @@ import unzipStream, { type Entry } from 'unzip-stream';
 import type Crowi from '~/server/crowi';
 import loggerFactory from '~/utils/logger';
 
+import type { ZipFileStat } from '../interfaces/export';
+
 import { tapStreamDataByPromise } from './unzip-stream-utils';
 
 
@@ -21,25 +23,23 @@ class GrowiBridgeService {
 
   crowi: Crowi;
 
-  encoding: string;
+  encoding: BufferEncoding = 'utf-8';
 
-  metaFileName: string;
+  metaFileName = 'meta.json';
 
-  baseDir: null;
+  baseDir: string | undefined;
 
   constructor(crowi: Crowi) {
     this.crowi = crowi;
-    this.encoding = 'utf-8';
-    this.metaFileName = 'meta.json';
   }
 
   /**
    * getter for encoding
    *
    * @memberOf GrowiBridgeService
-   * @return {string} encoding
+   * @return {BufferEncoding} encoding
    */
-  getEncoding() {
+  getEncoding(): BufferEncoding {
     return this.encoding;
   }
 
@@ -49,7 +49,7 @@ class GrowiBridgeService {
    * @memberOf GrowiBridgeService
    * @return {string} base name of meta file
    */
-  getMetaFileName() {
+  getMetaFileName(): string {
     return this.metaFileName;
   }
 
@@ -74,7 +74,7 @@ class GrowiBridgeService {
    * @param {string} zipFile path to zip file
    * @return {object} meta{object} and files{Array.<object>}
    */
-  async parseZipFile(zipFile) {
+  async parseZipFile(zipFile: string): Promise<ZipFileStat | null> {
     const fileStat = fs.statSync(zipFile);
     const innerFileStats: Array<{ fileName: string, collectionName: string, size: number }> = [];
     let meta = {};
@@ -124,4 +124,4 @@ class GrowiBridgeService {
 
 }
 
-module.exports = GrowiBridgeService;
+export default GrowiBridgeService;

+ 13 - 7
apps/app/src/server/service/in-app-notification.ts

@@ -5,6 +5,7 @@ import { SubscriptionStatusType } from '@growi/core';
 import { subDays } from 'date-fns/subDays';
 import type { Types, FilterQuery, UpdateQuery } from 'mongoose';
 
+import type { IPageBulkExportJob } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import { AllEssentialActions } from '~/interfaces/activity';
 import type { PaginateResult } from '~/interfaces/in-app-notification';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
@@ -51,7 +52,7 @@ export default class InAppNotificationService {
   }
 
   initActivityEventListeners(): void {
-    this.activityEvent.on('updated', async(activity: ActivityDocument, target: IUser | IPage, preNotify: PreNotify) => {
+    this.activityEvent.on('updated', async(activity: ActivityDocument, target: IUser | IPage | IPageBulkExportJob, preNotify: PreNotify) => {
       try {
         const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
         if (shouldNotification) {
@@ -119,21 +120,26 @@ export default class InAppNotificationService {
     const { limit, offset, status } = queryOptions;
 
     try {
-      const pagenateOptions = { user: userId };
+      const paginateOptions = { user: userId };
       if (status != null) {
-        Object.assign(pagenateOptions, { status });
+        Object.assign(paginateOptions, { status });
       }
       // TODO: import @types/mongoose-paginate-v2 and use PaginateResult as a type after upgrading mongoose v6.0.0
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
       const paginationResult = await (InAppNotification as any).paginate(
-        pagenateOptions,
+        paginateOptions,
         {
           sort: { createdAt: -1 },
           limit,
           offset,
           populate: [
             { path: 'user' },
-            { path: 'target' },
+            {
+              path: 'target',
+              populate: [
+                { path: 'attachment', strictPopulate: false },
+              ],
+            },
             { path: 'activities', populate: { path: 'user' } },
           ],
         },
@@ -191,13 +197,13 @@ export default class InAppNotificationService {
     return;
   };
 
-  createInAppNotification = async function(activity: ActivityDocument, target: IUser | IPage, preNotify: PreNotify): Promise<void> {
+  createInAppNotification = async function(activity: ActivityDocument, target: IUser | IPage | IPageBulkExportJob, preNotify: PreNotify): Promise<void> {
 
     const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
 
     const targetModel = activity.targetModel;
 
-    const snapshot = generateSnapshot(targetModel, target);
+    const snapshot = await generateSnapshot(targetModel, target);
 
     if (shouldNotification) {
       const props = preNotifyService.generateInitialPreNotifyProps();

+ 13 - 3
apps/app/src/server/service/in-app-notification/in-app-notification-utils.ts

@@ -1,19 +1,29 @@
 import type { IUser, IPage } from '@growi/core';
 
+import type { IPageBulkExportJob } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import { SupportedTargetModel } from '~/interfaces/activity';
 import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
+import * as pageBulkExportJobSerializers from '~/models/serializers/in-app-notification-snapshot/page-bulk-export-job';
 
-const isIPage = (targetModel: string, target: IUser | IPage): target is IPage => {
+const isIPage = (targetModel: string, target: IUser | IPage | IPageBulkExportJob): target is IPage => {
   return targetModel === SupportedTargetModel.MODEL_PAGE;
 };
 
-export const generateSnapshot = (targetModel: string, target: IUser | IPage) => {
+const isIPageBulkExportJob = (targetModel: string, target: IUser | IPage | IPageBulkExportJob): target is IPageBulkExportJob => {
+  return targetModel === SupportedTargetModel.MODEL_PAGE_BULK_EXPORT_JOB;
+};
+
+// snapshots are infos about the target that are displayed in the notification, which should not change on target update/deletion
+export const generateSnapshot = async(targetModel: string, target: IUser | IPage | IPageBulkExportJob): Promise<string | undefined> => {
 
-  let snapshot;
+  let snapshot: string | undefined;
 
   if (isIPage(targetModel, target)) {
     snapshot = pageSerializers.stringifySnapshot(target);
   }
+  else if (isIPageBulkExportJob(targetModel, target)) {
+    snapshot = await pageBulkExportJobSerializers.stringifySnapshot(target);
+  }
 
   return snapshot;
 };

+ 13 - 0
apps/app/src/server/service/interfaces/export.ts

@@ -0,0 +1,13 @@
+import { Stats } from 'fs';
+
+export type ZipFileStat = {
+  meta: object;
+  fileName: string;
+  zipFilePath: string;
+  fileStat: Stats;
+  innerFileStats: {
+      fileName: string;
+      collectionName: string;
+      size: number;
+  }[];
+}

+ 4 - 3
apps/app/src/server/service/pre-notify.ts

@@ -1,5 +1,6 @@
-import type {
-  IPage, IUser, Ref,
+import {
+  getIdForRef,
+  type IPage, type IUser, type Ref,
 } from '@growi/core';
 import mongoose from 'mongoose';
 
@@ -37,7 +38,7 @@ class PreNotifyService implements IPreNotifyService {
       const actionUser = activity.user;
       const target = activity.target;
       const subscribedUsers = await Subscription.getSubscription(target as unknown as Ref<IPage>);
-      const notificationUsers = subscribedUsers.filter(item => (item.toString() !== actionUser._id.toString()));
+      const notificationUsers = subscribedUsers.filter(item => (item.toString() !== getIdForRef(actionUser).toString()));
       const activeNotificationUsers = await User.find({
         _id: { $in: notificationUsers },
         status: User.STATUS_ACTIVE,

+ 33 - 0
apps/app/src/server/util/stream.spec.ts

@@ -0,0 +1,33 @@
+import { Readable, Writable, pipeline } from 'stream';
+import { promisify } from 'util';
+
+import { getBufferToFixedSizeTransform } from './stream';
+
+const pipelinePromise = promisify(pipeline);
+
+describe('stream util', () => {
+  describe('getBufferToFixedSizeTransform', () => {
+    it('should buffer data to fixed size and push to next stream', async() => {
+      const bufferSize = 10;
+      const chunks: Buffer[] = [];
+
+      const readable = Readable.from([Buffer.from('1234567890A'), Buffer.from('BCDE'), Buffer.from('FGH'), Buffer.from('IJKL')]);
+      const transform = getBufferToFixedSizeTransform(bufferSize);
+      const writable = new Writable({
+        write(chunk: Buffer, encoding, callback) {
+          chunks.push(chunk);
+          callback();
+        },
+      });
+
+      const expectedChunks = [Buffer.from('1234567890'), Buffer.from('ABCDEFGHIJ'), Buffer.from('KL')];
+
+      await pipelinePromise(readable, transform, writable);
+
+      expect(chunks).toHaveLength(expectedChunks.length);
+      expectedChunks.forEach((expectedChunk, index) => {
+        expect(chunks[index].toString()).toBe(expectedChunk.toString());
+      });
+    });
+  });
+});

+ 43 - 0
apps/app/src/server/util/stream.ts

@@ -1,3 +1,5 @@
+import { Transform } from 'stream';
+
 export const convertStreamToBuffer = (stream: any): Promise<Buffer> => {
 
   return new Promise((resolve, reject) => {
@@ -12,3 +14,44 @@ export const convertStreamToBuffer = (stream: any): Promise<Buffer> => {
 
   });
 };
+
+export const getBufferToFixedSizeTransform = (size: number): Transform => {
+  let buffer = Buffer.alloc(size);
+  let filledBufferSize = 0;
+
+  return new Transform({
+    transform(chunk: Buffer, encoding, callback) {
+      let offset = 0;
+      while (offset < chunk.length) {
+        // The data size to add to buffer.
+        // - If the remaining chunk size is smaller than the remaining buffer size:
+        //     - Add all of the remaining chunk to buffer => dataSize is the remaining chunk size
+        // - If the remaining chunk size is larger than the remaining buffer size:
+        //     - Fill the buffer, and upload => dataSize is the remaining buffer size
+        //     - The remaining chunk after upload will be added to buffer in the next iteration
+        const dataSize = Math.min(size - filledBufferSize, chunk.length - offset);
+        // Add chunk data to buffer
+        chunk.copy(buffer, filledBufferSize, offset, offset + dataSize);
+        filledBufferSize += dataSize;
+
+        // When buffer reaches size, push to next stream
+        if (filledBufferSize === size) {
+          this.push(buffer);
+          // Reset buffer after push
+          buffer = Buffer.alloc(size);
+          filledBufferSize = 0;
+        }
+
+        offset += dataSize;
+      }
+      callback();
+    },
+    flush(callback) {
+      // push the final buffer
+      if (filledBufferSize > 0) {
+        this.push(buffer.slice(0, filledBufferSize));
+      }
+      callback();
+    },
+  });
+};

+ 4 - 0
apps/app/src/stores-universal/context.tsx

@@ -160,6 +160,10 @@ export const useIsUploadAllFileAllowed = (initialData?: boolean): SWRResponse<bo
   return useContextSWR('isUploadAllFileAllowed', initialData);
 };
 
+export const useIsBulkExportPagesEnabled = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useContextSWR('isBulkExportPagesEnabled', initialData);
+};
+
 export const useShowPageLimitationL = (initialData?: number): SWRResponse<number, Error> => {
   return useContextSWR('showPageLimitationL', initialData);
 };

+ 2 - 2
apps/app/test/integration/service/external-user-group-sync.test.ts

@@ -9,7 +9,7 @@ import ExternalUserGroupRelation from '../../../src/features/external-user-group
 import ExternalUserGroupSyncService from '../../../src/features/external-user-group/server/service/external-user-group-sync';
 import ExternalAccount from '../../../src/server/models/external-account';
 import { configManager } from '../../../src/server/service/config-manager';
-import { instanciate } from '../../../src/server/service/external-account';
+import instanciateExternalAccountService from '../../../src/server/service/external-account';
 import PassportService from '../../../src/server/service/passport';
 import { getInstance } from '../setup-crowi';
 
@@ -184,7 +184,7 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
     crowi = await getInstance();
     await configManager.updateConfig('app:isV5Compatible', true);
     const passportService = new PassportService(crowi);
-    instanciate(passportService);
+    instanciateExternalAccountService(passportService);
   });
 
   beforeEach(async() => {

+ 1 - 0
apps/pdf-converter/.env

@@ -0,0 +1 @@
+PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium

+ 1 - 0
apps/pdf-converter/.eslintignore

@@ -0,0 +1 @@
+/dist/**

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