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

Merge branch 'feat/page-bulk-export' into imprv/149843-152624-disable-bulk-export-when-fs-not-s3-or-gcs

Futa Arai 1 год назад
Родитель
Сommit
df7105e51d
27 измененных файлов с 261 добавлено и 54 удалено
  1. 28 1
      CHANGELOG.md
  2. 1 1
      apps/app/docker/README.md
  3. 1 1
      apps/app/package.json
  4. 5 1
      apps/app/public/static/locales/en_US/translation.json
  5. 5 1
      apps/app/public/static/locales/fr_FR/translation.json
  6. 5 1
      apps/app/public/static/locales/ja_JP/translation.json
  7. 5 1
      apps/app/public/static/locales/zh_CN/translation.json
  8. 49 6
      apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx
  9. 1 0
      apps/app/src/features/page-bulk-export/interfaces/page-bulk-export.ts
  10. 1 0
      apps/app/src/features/page-bulk-export/server/models/page-bulk-export-job.ts
  11. 3 2
      apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts
  12. 12 2
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron.ts
  13. 8 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export/errors.ts
  14. 55 9
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export/index.ts
  15. 8 3
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export/page-bulk-export-job-stream-manager.ts
  16. 4 5
      apps/app/src/pages/[[...path]].page.tsx
  17. 23 5
      apps/app/src/server/models/page.ts
  18. 8 1
      apps/app/src/server/routes/apiv3/page-listing.ts
  19. 9 0
      apps/app/src/server/routes/apiv3/pages/index.js
  20. 8 3
      apps/app/src/server/service/page/index.ts
  21. 3 1
      apps/app/src/server/service/page/page-service.ts
  22. 1 1
      apps/slackbot-proxy/package.json
  23. 1 1
      package.json
  24. 6 0
      packages/core/CHANGELOG.md
  25. 1 1
      packages/core/package.json
  26. 9 6
      packages/core/src/interfaces/page.ts
  27. 1 1
      yarn.lock

+ 28 - 1
CHANGELOG.md

@@ -1,9 +1,36 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.16...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.0.17...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.0.17](https://github.com/weseek/growi/compare/v7.0.16...v7.0.17) - 2024-08-26
+
+### 🚀 Improvement
+
+* imprv: Serializers for User model and Attachment model (#9019) @yuki-takei
+* imprv: translation modification (#9035) @maeshinshin
+* imprv: Add UI and logic for disabled user registration (#9034) @maeshinshin
+* imprv: lang attribute in Html element (#8960) @maeshinshin
+
+### 🐛 Bug Fixes
+
+* fix: Serializer for accessing to an empty page (#9042) @yuki-takei
+* fix: Import data (#8994) @yuki-takei
+* fix: Comment operation by API (#9026) @yuki-takei
+* fix: Tests fail due to docker image and Playwright  version mismatch on CI (#9022) @miya
+* fix: Use the scrollbar to prevent the toolbar from being hidden (#8976) @maeshinshin
+* fix: Revision pageId schema type (#9008) @yuki-takei
+* fix: Revision pageId schema type (add a changeset) (#9010) @yuki-takei
+* fix: Hide WideViewMenuItem in search result (#9009) @yuki-takei
+* fix: Wrongly autofocus to PageHeader even after updating (#9011) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Dark mode support for CountBadge (#9036) @satof3
+* support: Update import lines (#9018) @yuki-takei
+* support: Typescriptize REPL launcher (#9013) @yuki-takei
+
 ## [v7.0.16](https://github.com/weseek/growi/compare/v7.0.15...v7.0.16) - 2024-07-31
 
 ### 💎 Features

+ 1 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.0.16`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.16/apps/app/docker/Dockerfile)
+* [`7.0.17`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.17/apps/app/docker/Dockerfile)
 * [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
 * [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)

+ 1 - 1
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.0.17-RC.0",
+  "version": "7.0.18-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 5 - 1
apps/app/public/static/locales/en_US/translation.json

@@ -643,7 +643,11 @@
     "bulk_export_download_expired": "Download period has expired",
     "bulk_export_job_expired": "Export process was canceled because it took too long",
     "duplicate_bulk_export_job_error": "Export for the same page and its children is in progress",
-    "bulk_export_only_available_for": "Only available for AWS or GCP"
+    "bulk_export_only_available_for": "Only available for AWS or GCP",
+    "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 export in progress will be canceled",
+    "restart": "Restart"
   },
   "message": {
     "successfully_connected": "Successfully Connected!",

+ 5 - 1
apps/app/public/static/locales/fr_FR/translation.json

@@ -637,7 +637,11 @@
     "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",
     "duplicate_bulk_export_job_error": "L'export pour la même page et ses enfants est en cours",
-    "bulk_export_only_available_for": "Uniquement disponible pour AWS ou GCP"
+    "bulk_export_only_available_for": "Uniquement disponible pour AWS ou GCP",
+    "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": "L'export en cours sera annulé",
+    "restart": "Redémarrage"
   },
   "message": {
     "successfully_connected": "Connecté!",

+ 5 - 1
apps/app/public/static/locales/ja_JP/translation.json

@@ -676,7 +676,11 @@
     "bulk_export_download_expired": "ダウンロード期限が切れました",
     "bulk_export_job_expired": "エクスポート時間が長すぎるため、処理が中断されました",
     "duplicate_bulk_export_job_error": "既に同じページとその配下のエクスポートが進行中です",
-    "bulk_export_only_available_for": "AWS と GCP のみ対応しています"
+    "bulk_export_only_available_for": "AWS と GCP のみ対応しています",
+    "export_in_progress": "エクスポート進行中",
+    "export_in_progress_explanation": "既に同じ形式でのエクスポートが進行中です。最新のページ内容でエクスポートを最初からやり直しますか?",
+    "export_cancel_warning": "進行中のエクスポートはキャンセルされます",
+    "restart": "やり直す"
   },
   "message": {
     "successfully_connected": "接続に成功しました!",

+ 5 - 1
apps/app/public/static/locales/zh_CN/translation.json

@@ -646,7 +646,11 @@
     "bulk_export_download_expired": "下载期限已过",
     "bulk_export_job_expired": "由于导出时间太长,处理被中断",
     "duplicate_bulk_export_job_error": "正在导出同一页面及其子页面",
-    "bulk_export_only_available_for": "仅适用于 AWS 或 GCP"
+    "bulk_export_only_available_for": "仅适用于 AWS 或 GCP",
+    "export_in_progress": "导出正在进行中",
+    "export_in_progress_explanation": "已在进行相同格式的导出。您要重新启动以导出最新的页面内容吗?",
+    "export_cancel_warning": "正在进行的导出将被取消",
+    "restart": "重新开始"
   },
   "message": {
     "successfully_connected": "连接成功!",

+ 49 - 6
apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx

@@ -1,3 +1,5 @@
+import { useState } from 'react';
+
 import { useTranslation } from 'next-i18next';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 
@@ -12,42 +14,83 @@ const PageBulkExportSelectModal = (): JSX.Element => {
   const { data: status, close } = usePageBulkExportSelectModal();
   const { data: currentPagePath } = useCurrentPagePath();
 
+  const [isRestartModalOpened, setIsRestartModalOpened] = useState(false);
+  const [formatMemoForRestart, setFormatMemoForRestart] = useState<PageBulkExportFormat | null>(null);
+
   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) {
-      // TODO: Enable cancel and restart of export if duplicate export exists (https://redmine.weseek.co.jp/issues/150418)
       const errorCode = e?.[0].code ?? 'page_export.failed_to_export';
-      toastError(t(errorCode));
+      if (errorCode === 'page_export.duplicate_bulk_export_job_error') {
+        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}>
-          <ModalHeader tag="h5" toggle={close} className="bg-primary text-light">
+          <ModalHeader tag="h4" toggle={close}>
             {t('page_export.bulk_export')}
           </ModalHeader>
           <ModalBody>
             {t('page_export.choose_export_format')}
-            <div className="my-2">
+            <div className="my-1">
               <small className="text-muted">
                 {t('page_export.bulk_export_notice')}
               </small>
             </div>
-            <div className="d-flex justify-content-center mt-2">
+            <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>
+              {/* TODO: enable in https://redmine.weseek.co.jp/issues/135772 */}
+              {/* <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="my-1 text-danger">
+            <small className="text-danger">
+              {t('page_export.export_cancel_warning')}
+            </small>
+          </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>
     </>
   );
 };

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

@@ -36,6 +36,7 @@ export interface IPageBulkExportJob {
   completedAt?: Date, // the date at which job was completed
   attachment?: Ref<IAttachment>,
   status: PageBulkExportJobStatus,
+  revisionListHash?: string, // Hash created from the list of revision IDs. Used to detect existing duplicate uploads.
 }
 
 export interface IPageBulkExportJobHasId extends IPageBulkExportJob, HasObjectId {}

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

@@ -21,6 +21,7 @@ const pageBulkExportJobSchema = new Schema<PageBulkExportJobDocument>({
   status: {
     type: String, enum: Object.values(PageBulkExportJobStatus), required: true, default: PageBulkExportJobStatus.initializing,
   },
+  revisionListHash: { type: String },
 }, { timestamps: true });
 
 export default getOrCreateModel<PageBulkExportJobDocument, PageBulkExportJobModel>('PageBulkExportJob', pageBulkExportJobSchema);

+ 3 - 2
apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts

@@ -25,6 +25,7 @@ module.exports = (crowi: Crowi): Router => {
     pageBulkExport: [
       body('path').exists({ checkFalsy: true }).isString(),
       body('format').exists({ checkFalsy: true }).isString(),
+      body('restartJob').isBoolean().optional(),
     ],
   };
 
@@ -34,14 +35,14 @@ module.exports = (crowi: Crowi): Router => {
       return res.status(400).json({ errors: errors.array() });
     }
 
-    const { path, format } = req.body;
+    const { path, format, restartJob } = req.body;
     const activityParameters = {
       ip: req.ip,
       endpoint: req.originalUrl,
     };
 
     try {
-      await pageBulkExportService?.createAndExecuteBulkExportJob(path, req.user, activityParameters);
+      await pageBulkExportService?.createAndExecuteOrRestartBulkExportJob(path, req.user, activityParameters, restartJob);
       return res.apiv3({}, 204);
     }
     catch (err) {

+ 12 - 2
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron.ts

@@ -57,14 +57,24 @@ class PageBulkExportJobCronService extends CronService {
    */
   async deleteDownloadExpiredExportJobs() {
     const downloadExpirationSeconds = configManager.getConfig('crowi', 'app:bulkExportDownloadExpirationSeconds');
+    const thresholdDate = new Date(Date.now() - downloadExpirationSeconds * 1000);
     const downloadExpiredExportJobs = await PageBulkExportJob.find({
       status: PageBulkExportJobStatus.completed,
-      completedAt: { $lt: new Date(Date.now() - downloadExpirationSeconds * 1000) },
+      completedAt: { $lt: thresholdDate },
     });
 
     const cleanup = async(job: PageBulkExportJobDocument) => {
       await pageBulkExportService?.cleanUpExportJobResources(job);
-      await this.crowi.attachmentService?.removeAttachment(job.attachment);
+
+      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);

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

@@ -13,3 +13,11 @@ export class BulkExportJobExpiredError extends Error {
   }
 
 }
+
+export class BulkExportJobRestartedError extends Error {
+
+  constructor() {
+    super('Bulk export job has restarted');
+  }
+
+}

+ 55 - 9
apps/app/src/features/page-bulk-export/server/service/page-bulk-export/index.ts

@@ -1,10 +1,11 @@
+import { createHash } from 'crypto';
 import fs from 'fs';
 import path from 'path';
 import { Writable } from 'stream';
 import { pipeline as pipelinePromise } from 'stream/promises';
 
 import {
-  getIdForRef, type IPage, isPopulated, SubscriptionStatusType,
+  getIdForRef, getIdStringForRef, type IPage, isPopulated, SubscriptionStatusType,
 } from '@growi/core';
 import { getParentPath, normalizePath } from '@growi/core/dist/utils/path-utils';
 import type { Archiver } from 'archiver';
@@ -33,7 +34,7 @@ import PageBulkExportJob 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';
 
-import { BulkExportJobExpiredError, DuplicateBulkExportJobError } from './errors';
+import { BulkExportJobExpiredError, BulkExportJobRestartedError, DuplicateBulkExportJobError } from './errors';
 import { PageBulkExportJobStreamManager } from './page-bulk-export-job-stream-manager';
 
 
@@ -74,7 +75,7 @@ class PageBulkExportService {
   /**
    * Create a new page bulk export job and execute it
    */
-  async createAndExecuteBulkExportJob(basePagePath: string, currentUser, activityParameters: ActivityParameters): Promise<void> {
+  async createAndExecuteOrRestartBulkExportJob(basePagePath: string, currentUser, activityParameters: ActivityParameters, restartJob = false): Promise<void> {
     const basePage = await this.pageModel.findByPathAndViewer(basePagePath, currentUser, null, true);
 
     if (basePage == null) {
@@ -89,6 +90,10 @@ class PageBulkExportService {
       $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
     });
     if (duplicatePageBulkExportJobInProgress != null) {
+      if (restartJob) {
+        this.restartBulkExportJob(duplicatePageBulkExportJobInProgress);
+        return;
+      }
       throw new DuplicateBulkExportJobError();
     }
     const pageBulkExportJob: HydratedDocument<PageBulkExportJobDocument> = await PageBulkExportJob.create({
@@ -100,6 +105,17 @@ class PageBulkExportService {
     this.executePageBulkExportJob(pageBulkExportJob, activityParameters);
   }
 
+  /**
+   * Restart page bulk export job in progress from the beginning
+   */
+  async restartBulkExportJob(pageBulkExportJob: HydratedDocument<PageBulkExportJobDocument>): Promise<void> {
+    await this.cleanUpExportJobResources(pageBulkExportJob, true);
+
+    pageBulkExportJob.status = PageBulkExportJobStatus.initializing;
+    await pageBulkExportJob.save();
+    this.executePageBulkExportJob(pageBulkExportJob);
+  }
+
   /**
    * Execute a page bulk export job. This method can also resume a previously inturrupted job.
    */
@@ -110,7 +126,22 @@ class PageBulkExportService {
 
       if (pageBulkExportJob.status === PageBulkExportJobStatus.initializing) {
         await this.createPageSnapshots(user, pageBulkExportJob);
-        pageBulkExportJob.status = PageBulkExportJobStatus.exporting;
+
+        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;
+        }
+        else {
+          pageBulkExportJob.status = PageBulkExportJobStatus.exporting;
+        }
         await pageBulkExportJob.save();
       }
       if (pageBulkExportJob.status === PageBulkExportJobStatus.exporting) {
@@ -123,11 +154,16 @@ class PageBulkExportService {
       }
     }
     catch (err) {
-      logger.error(err);
       if (err instanceof BulkExportJobExpiredError) {
+        logger.error(err);
         await this.notifyExportResultAndCleanUp(SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED, pageBulkExportJob, activityParameters);
       }
+      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, activityParameters);
       }
       return;
@@ -162,7 +198,8 @@ class PageBulkExportService {
   }
 
   /**
-   * Create a snapshot for each page that is to be exported in the pageBulkExportJob
+   * Create a snapshot for each page that is to be exported in the pageBulkExportJob.
+   * Also calulate revisionListHash and save it to the pageBulkExportJob.
    */
   private async createPageSnapshots(user, pageBulkExportJob: PageBulkExportJobDocument): Promise<void> {
     // if the process of creating snapshots was interrupted, delete the snapshots and create from the start
@@ -173,6 +210,8 @@ class PageBulkExportService {
       throw new Error('Base page not found');
     }
 
+    const revisionListHash = createHash('sha256');
+
     // create a Readable for pages to be exported
     const { PageQueryBuilder } = this.pageModel;
     const builder = await new PageQueryBuilder(this.pageModel.find())
@@ -188,6 +227,9 @@ class PageBulkExportService {
       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,
@@ -205,6 +247,9 @@ class PageBulkExportService {
     this.pageBulkExportJobStreamManager.addJobStream(pageBulkExportJob._id, pagesReadable);
 
     await pipelinePromise(pagesReadable, pageSnapshotsWritable);
+
+    pageBulkExportJob.revisionListHash = revisionListHash.digest('hex');
+    await pageBulkExportJob.save();
   }
 
   /**
@@ -267,7 +312,8 @@ class PageBulkExportService {
     const pageArchiver = this.setUpPageArchiver();
     const bufferToPartSizeTransform = getBufferToFixedSizeTransform(this.maxPartSize);
 
-    const originalName = `${pageBulkExportJob._id}.${this.compressExtension}`;
+    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 uploadKey = `${FilePathOnStoragePrefix.pageBulkExport}/${attachment.fileName}`;
 
@@ -382,8 +428,8 @@ class PageBulkExportService {
    * - remove the temporal output directory
    * - abort multipart upload
    */
-  async cleanUpExportJobResources(pageBulkExportJob: PageBulkExportJobDocument) {
-    this.pageBulkExportJobStreamManager.destroyJobStream(pageBulkExportJob._id);
+  async cleanUpExportJobResources(pageBulkExportJob: PageBulkExportJobDocument, restarted = false) {
+    this.pageBulkExportJobStreamManager.destroyJobStream(pageBulkExportJob._id, restarted);
 
     const promises = [
       PageBulkExportPageSnapshot.deleteMany({ pageBulkExportJob }),

+ 8 - 3
apps/app/src/features/page-bulk-export/server/service/page-bulk-export/page-bulk-export-job-stream-manager.ts

@@ -2,7 +2,7 @@ import type { Readable } from 'stream';
 
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
-import { BulkExportJobExpiredError } from './errors';
+import { BulkExportJobExpiredError, BulkExportJobRestartedError } from './errors';
 
 /**
  * Used to keep track of streams currently being executed, and enable destroying them
@@ -22,10 +22,15 @@ export class PageBulkExportJobStreamManager {
     delete this.jobStreams[jobId.toString()];
   }
 
-  destroyJobStream(jobId: ObjectIdLike): void {
+  destroyJobStream(jobId: ObjectIdLike, restarted = false): void {
     const stream = this.jobStreams[jobId.toString()];
     if (stream != null) {
-      stream.destroy(new BulkExportJobExpiredError());
+      if (restarted) {
+        stream.destroy(new BulkExportJobRestartedError());
+      }
+      else {
+        stream.destroy(new BulkExportJobExpiredError());
+      }
     }
     this.removeJobStream(jobId);
   }

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

@@ -3,9 +3,9 @@ import React, { useEffect } from 'react';
 
 import EventEmitter from 'events';
 
-import { isIPageInfoForEntity } from '@growi/core';
+import { isIPageInfo } from '@growi/core';
 import type {
-  IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision,
+  IDataWithMeta, IPageInfo, IPagePopulatedToShowRevision,
 } from '@growi/core';
 import {
   isClient, pagePathUtils, pathUtils,
@@ -101,7 +101,7 @@ const {
 } = pagePathUtils;
 const { removeHeadingSlash } = pathUtils;
 
-type IPageToShowRevisionWithMeta = IDataWithMeta<IPagePopulatedToShowRevision & PageDocument, IPageInfoForEntity>;
+type IPageToShowRevisionWithMeta = IDataWithMeta<IPagePopulatedToShowRevision & PageDocument, IPageInfo>;
 type IPageToShowRevisionWithMetaSerialized = IDataWithMeta<string, string>;
 
 superjson.registerCustom<IPageToShowRevisionWithMeta, IPageToShowRevisionWithMetaSerialized>(
@@ -109,8 +109,7 @@ superjson.registerCustom<IPageToShowRevisionWithMeta, IPageToShowRevisionWithMet
     isApplicable: (v): v is IPageToShowRevisionWithMeta => {
       return v?.data != null
         && v?.data.toObject != null
-        && v?.meta != null
-        && isIPageInfoForEntity(v.meta);
+        && isIPageInfo(v.meta);
     },
     serialize: (v) => {
       return {

+ 23 - 5
apps/app/src/server/models/page.ts

@@ -65,6 +65,18 @@ type PaginatedPages = {
   offset: number
 }
 
+export type FindRecentUpdatedPagesOption = {
+  offset: number,
+  limit: number,
+  includeWipPage: boolean,
+  includeTrashed: boolean,
+  isRegExpEscapedFromPath: boolean,
+  sort: 'updatedAt'
+  desc: number
+  hideRestrictedByOwner: boolean,
+  hideRestrictedByGroup: boolean,
+}
+
 export type CreateMethod = (path: string, body: string, user, options: IOptionsForCreate) => Promise<HydratedDocument<PageDocument>>
 
 export interface PageModel extends Model<PageDocument> {
@@ -79,7 +91,7 @@ export interface PageModel extends Model<PageDocument> {
   countByPathAndViewer(path: string | null, user, userGroups?, includeEmpty?:boolean): Promise<number>
   findParentByPath(path: string | null): Promise<HydratedDocument<PageDocument> | null>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
-  findRecentUpdatedPages(path: string, user, option, includeEmpty?: boolean): Promise<PaginatedPages>
+  findRecentUpdatedPages(path: string, user, option: FindRecentUpdatedPagesOption, includeEmpty?: boolean): Promise<PaginatedPages>
   generateGrantCondition(
     user, userGroups: ObjectIdLike[] | null, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
@@ -414,13 +426,19 @@ export class PageQueryBuilder {
   }
 
   // add viewer condition to PageQueryBuilder instance
-  async addViewerCondition(user, userGroups = null, includeAnyoneWithTheLink = false): Promise<PageQueryBuilder> {
+  async addViewerCondition(
+      user,
+      userGroups = null,
+      includeAnyoneWithTheLink = false,
+      showPagesRestrictedByOwner = false,
+      showPagesRestrictedByGroup = false,
+  ): Promise<PageQueryBuilder> {
     const relatedUserGroups = (user != null && userGroups == null) ? [
       ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
       ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
     ] : userGroups;
 
-    this.addConditionToFilteringByViewer(user, relatedUserGroups, includeAnyoneWithTheLink);
+    this.addConditionToFilteringByViewer(user, relatedUserGroups, includeAnyoneWithTheLink, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
     return this;
   }
 
@@ -664,7 +682,7 @@ schema.statics.countByPathAndViewer = async function(path: string | null, user,
 };
 
 schema.statics.findRecentUpdatedPages = async function(
-    path: string, user, options, includeEmpty = false,
+    path: string, user, options: FindRecentUpdatedPagesOption, includeEmpty = false,
 ): Promise<PaginatedPages> {
 
   const sortOpt = {};
@@ -690,7 +708,7 @@ schema.statics.findRecentUpdatedPages = async function(
 
   queryBuilder.addConditionToListWithDescendants(path, options);
   queryBuilder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
-  await queryBuilder.addViewerCondition(user);
+  await queryBuilder.addViewerCondition(user, undefined, undefined, !options.hideRestrictedByOwner, !options.hideRestrictedByGroup);
   const pages = await Page.paginate(queryBuilder.query.clone(), {
     lean: true, sort: sortOpt, offset: options.offset, limit: options.limit,
   });

+ 8 - 1
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -9,6 +9,7 @@ import { query, oneOf } from 'express-validator';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 
+import { configManager } from '~/server/service/config-manager';
 import type { IPageGrantService } from '~/server/service/page-grant';
 import loggerFactory from '~/utils/logger';
 
@@ -18,6 +19,7 @@ import type { PageDocument, PageModel } from '../../models/page';
 
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
+
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
 
 /*
@@ -103,8 +105,13 @@ const routerFactory = (crowi: Crowi): Router => {
 
     const pageService = crowi.pageService;
 
+    const hideRestrictedByOwner = await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner');
+    const hideRestrictedByGroup = await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup');
+
     try {
-      const pages = await pageService.findChildrenByParentPathOrIdAndViewer((id || path)as string, req.user);
+      const pages = await pageService.findChildrenByParentPathOrIdAndViewer(
+        (id || path)as string, req.user, undefined, !hideRestrictedByOwner, !hideRestrictedByGroup,
+      );
       return res.apiv3({ children: pages });
     }
     catch (err) {

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

@@ -226,6 +226,12 @@ module.exports = (crowi) => {
     const offset = parseInt(req.query.offset) || 0;
     const includeWipPage = req.query.includeWipPage === 'true'; // Need validation using express-validator
 
+    const hideRestrictedByOwner = await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner');
+    const hideRestrictedByGroup = await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup');
+
+    /**
+    * @type {import('~/server/models/page').FindRecentUpdatedPagesOption}
+    */
     const queryOptions = {
       offset,
       limit,
@@ -234,7 +240,10 @@ module.exports = (crowi) => {
       isRegExpEscapedFromPath: true,
       sort: 'updatedAt',
       desc: -1,
+      hideRestrictedByOwner,
+      hideRestrictedByGroup,
     };
+
     try {
       const result = await Page.findRecentUpdatedPages('/', req.user, queryOptions);
       if (result.pages.length > limit) {

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

@@ -4332,8 +4332,13 @@ class PageService implements IPageService {
   /*
    * Find all children by parent's path or id. Using id should be prioritized
    */
-  async findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups = null)
-      : Promise<(HydratedDocument<PageDocument> & { processData?: IPageOperationProcessData })[]> {
+  async findChildrenByParentPathOrIdAndViewer(
+      parentPathOrId: string,
+      user,
+      userGroups = null,
+      showPagesRestrictedByOwner = false,
+      showPagesRestrictedByGroup = false,
+  ): Promise<(HydratedDocument<PageDocument> & { processData?: IPageOperationProcessData })[]> {
     const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
     let queryBuilder: PageQueryBuilder;
     if (hasSlash(parentPathOrId)) {
@@ -4346,7 +4351,7 @@ class PageService implements IPageService {
       // Use $eq for user-controlled sources. see: https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/#recommendation
       queryBuilder = new PageQueryBuilder(Page.find({ parent: { $eq: parentId } } as any), true); // TODO: improve type
     }
-    await queryBuilder.addViewerCondition(user, userGroups);
+    await queryBuilder.addViewerCondition(user, userGroups, undefined, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
 
     const pages: HydratedDocument<PageDocument>[] = await queryBuilder
       .addConditionToSortPagesByAscPath()

+ 3 - 1
apps/app/src/server/service/page/page-service.ts

@@ -21,7 +21,9 @@ export interface IPageService {
   getEventEmitter: () => EventEmitter,
   deleteMultipleCompletely: (pages: ObjectIdLike[], user: IUser | undefined) => Promise<void>,
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>,
-  findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>,
+  findChildrenByParentPathOrIdAndViewer(
+    parentPathOrId: string, user, userGroups?, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
+  ): Promise<PageDocument[]>,
   shortBodiesMapByPageIds(pageIds?: Types.ObjectId[], user?): Promise<Record<string, string | null>>,
   constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | Omit<IPageInfoForEntity, 'bookmarkCount'>,
   normalizeAllPublicPages(): Promise<void>,

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.0.17-slackbot-proxy.0",
+  "version": "7.0.18-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.0.17-RC.0",
+  "version": "7.0.18-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",

+ 6 - 0
packages/core/CHANGELOG.md

@@ -1,5 +1,11 @@
 # @growi/core
 
+## 1.3.0
+
+### Minor Changes
+
+- [#9042](https://github.com/weseek/growi/pull/9042) [`8f9189d`](https://github.com/weseek/growi/commit/8f9189d4fcf031c1344072f88b7d9febeb02ce1d) Thanks [@yuki-takei](https://github.com/yuki-takei)! - Add isIPageInfo type guard
+
 ## 1.2.0
 
 ### Minor Changes

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "1.2.0",
+  "version": "1.3.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 9 - 6
packages/core/src/interfaces/page.ts

@@ -101,23 +101,26 @@ export type IPageInfoForListing = IPageInfoForEntity & HasRevisionShortbody;
 export type IPageInfoAll = IPageInfo | IPageInfoForEntity | IPageInfoForOperation | IPageInfoForListing;
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-export const isIPageInfoForEntity = (pageInfo: any | undefined): pageInfo is IPageInfoForEntity => {
+export const isIPageInfo = (pageInfo: any | undefined): pageInfo is IPageInfo => {
   return pageInfo != null && pageInfo instanceof Object
-    && ('isEmpty' in pageInfo)
+    && ('isEmpty' in pageInfo);
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const isIPageInfoForEntity = (pageInfo: any | undefined): pageInfo is IPageInfoForEntity => {
+  return isIPageInfo(pageInfo)
     && pageInfo.isEmpty === false;
 };
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export const isIPageInfoForOperation = (pageInfo: any | undefined): pageInfo is IPageInfoForOperation => {
-  return pageInfo != null
-    && isIPageInfoForEntity(pageInfo)
+  return isIPageInfoForEntity(pageInfo)
     && ('isBookmarked' in pageInfo || 'isLiked' in pageInfo || 'subscriptionStatus' in pageInfo);
 };
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export const isIPageInfoForListing = (pageInfo: any | undefined): pageInfo is IPageInfoForListing => {
-  return pageInfo != null
-    && isIPageInfoForEntity(pageInfo)
+  return isIPageInfoForEntity(pageInfo)
     && 'revisionShortBody' in pageInfo;
 };
 

+ 1 - 1
yarn.lock

@@ -2137,7 +2137,7 @@
   version "1.0.0"
 
 "@growi/core@link:packages/core":
-  version "1.2.0"
+  version "1.3.0"
   dependencies:
     bson-objectid "^2.0.4"
     escape-string-regexp "^4.0.0"