Răsfoiți Sursa

Merge pull request #8770 from weseek/feat/78037-144924-download-bulk-export-from-in-app-notification-BE

Feat/78037 144924 download bulk export from in app notification be
Yuki Takei 1 an în urmă
părinte
comite
aae518b9fd

+ 2 - 6
apps/app/src/features/page-bulk-export/interfaces/page-bulk-export.ts

@@ -15,12 +15,8 @@ export interface IPageBulkExportJob {
   lastUploadedPagePath: string, // the path of page that was uploaded last
   uploadId: string, // upload ID of multipart upload of S3/GCS
   format: PageBulkExportFormat,
-  expireAt: Date, // the date at which job execution expires
-}
-
-export interface IPageBulkExportResult {
-  attachment: Ref<IAttachment>,
-  expireAt: Date, // the date at which downloading of result expires
+  completedAt: Date | null, // the date at which job was completed
+  attachment?: Ref<IAttachment>,
 }
 
 // snapshot of page info to upload

+ 6 - 4
apps/app/src/features/page-bulk-export/server/models/page-bulk-export-job.ts

@@ -2,7 +2,8 @@ import { type Document, type Model, Schema } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
-import { IPageBulkExportJob, PageBulkExportFormat } from '../../interfaces/page-bulk-export';
+import type { IPageBulkExportJob } from '../../interfaces/page-bulk-export';
+import { PageBulkExportFormat } from '../../interfaces/page-bulk-export';
 
 export interface PageBulkExportJobDocument extends IPageBulkExportJob, Document {}
 
@@ -11,10 +12,11 @@ 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 },
-  lastUploadedPagePath: { type: String, required: true },
-  uploadId: { type: String, required: true },
+  lastUploadedPagePath: { type: String },
+  uploadId: { type: String, required: true, unique: true },
   format: { type: String, enum: Object.values(PageBulkExportFormat), required: true },
-  expireAt: { type: Date, required: true },
+  completedAt: { type: Date },
+  attachment: { type: Schema.Types.ObjectId, ref: 'Attachment' },
 }, { timestamps: true });
 
 export default getOrCreateModel<PageBulkExportJobDocument, PageBulkExportJobModel>('PageBulkExportJob', pageBulkExportJobSchema);

+ 0 - 16
apps/app/src/features/page-bulk-export/server/models/page-bulk-export-result.ts

@@ -1,16 +0,0 @@
-import { type Document, type Model, Schema } from 'mongoose';
-
-import { getOrCreateModel } from '~/server/util/mongoose-utils';
-
-import { IPageBulkExportResult } from '../../interfaces/page-bulk-export';
-
-export interface PageBulkExportResultDocument extends IPageBulkExportResult, Document {}
-
-export type PageBulkExportResultModel = Model<PageBulkExportResultDocument>
-
-const pageBulkExportResultSchema = new Schema<PageBulkExportResultDocument>({
-  attachment: { type: Schema.Types.ObjectId, ref: 'Attachment', required: true },
-  expireAt: { type: Date, required: true },
-}, { timestamps: true });
-
-export default getOrCreateModel<PageBulkExportResultDocument, PageBulkExportResultModel>('PageBulkExportResult', pageBulkExportResultSchema);

+ 17 - 6
apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts

@@ -1,9 +1,10 @@
 import { ErrorV3 } from '@growi/core/dist/models';
-import { Router, Request } from 'express';
+import type { Request } from 'express';
+import { Router } from 'express';
 import { body, validationResult } from 'express-validator';
 
-import Crowi from '~/server/crowi';
-import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import type Crowi from '~/server/crowi';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
 import { pageBulkExportService } from '../../service/page-bulk-export';
@@ -33,9 +34,19 @@ module.exports = (crowi: Crowi): Router => {
     }
 
     const { path, format } = req.body;
-
-    pageBulkExportService?.bulkExportWithBasePagePath(path);
-    return res.apiv3({}, 204);
+    const activityParameters = {
+      ip: req.ip,
+      endpoint: req.originalUrl,
+    };
+
+    try {
+      await pageBulkExportService?.bulkExportWithBasePagePath(path, req.user, activityParameters);
+      return res.apiv3({}, 204);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Failed to start bulk export'));
+    }
   });
 
   return router;

+ 91 - 18
apps/app/src/features/page-bulk-export/server/service/page-bulk-export.ts

@@ -1,7 +1,8 @@
 import type { Readable } from 'stream';
 import { Writable, pipeline } from 'stream';
 
-import { type IPage, isPopulated } from '@growi/core';
+import type { HasObjectId } from '@growi/core';
+import { type IPage, isPopulated, SubscriptionStatusType } from '@growi/core';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import type { Archiver } from 'archiver';
 import archiver from 'archiver';
@@ -9,11 +10,22 @@ import type { QueueObject } from 'async';
 import gc from 'expose-gc/function';
 import mongoose from 'mongoose';
 
+import type { SupportedActionType } from '~/interfaces/activity';
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import { AttachmentType, FilePathOnStoragePrefix } from '~/server/interfaces/attachment';
+import type { IAttachmentDocument } from '~/server/models';
+import { Attachment } from '~/server/models';
+import type { ActivityDocument } from '~/server/models/activity';
 import type { PageModel, PageDocument } from '~/server/models/page';
+import Subscription from '~/server/models/subscription';
 import type { IAwsMultipartUploader } from '~/server/service/file-uploader/aws/multipart-upload';
+import { preNotifyService } from '~/server/service/pre-notify';
 import { getBufferToFixedSizeTransform } from '~/server/util/stream';
 import loggerFactory from '~/utils/logger';
 
+import { PageBulkExportFormat } 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');
 
@@ -22,10 +34,17 @@ interface ArchiverWithQueue extends Archiver {
   _queue?: QueueObject<any>;
 }
 
+type ActivityParameters ={
+  ip: string;
+  endpoint: string;
+}
+
 class PageBulkExportService {
 
   crowi: any;
 
+  activityEvent: any;
+
   // multipart upload part size
   partSize = 5 * 1024 * 1024; // 5MB
 
@@ -33,11 +52,21 @@ class PageBulkExportService {
 
   constructor(crowi) {
     this.crowi = crowi;
+    this.activityEvent = crowi.event('activity');
   }
 
-  async bulkExportWithBasePagePath(basePagePath: string): Promise<void> {
+  async bulkExportWithBasePagePath(basePagePath: string, currentUser, activityParameters: ActivityParameters): 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 timeStamp = (new Date()).getTime();
-    const uploadKey = `page-bulk-export-${timeStamp}.zip`;
+    const originalName = `page-bulk-export-${timeStamp}.zip`;
+    const attachment = Attachment.createWithoutSave(null, currentUser, originalName, 'zip', 0, AttachmentType.PAGE_BULK_EXPORT);
+    const uploadKey = `${FilePathOnStoragePrefix.pageBulkExport}/${attachment.fileName}`;
 
     const pagesReadable = this.getPageReadable(basePagePath);
     const zipArchiver = this.setUpZipArchiver();
@@ -47,31 +76,42 @@ class PageBulkExportService {
     // init multipart upload
     // TODO: Create abstract interface IMultipartUploader in https://redmine.weseek.co.jp/issues/135775
     const multipartUploader: IAwsMultipartUploader | undefined = this.crowi?.fileUploadService?.createMultipartUploader(uploadKey);
+    let pageBulkExportJob: PageBulkExportJobDocument & HasObjectId;
+    if (multipartUploader == null) {
+      throw Error('Multipart upload not available for configured file upload type');
+    }
     try {
-      if (multipartUploader == null) {
-        throw Error('Multipart upload not available for configured file upload type');
-      }
       await multipartUploader.initUpload();
+      pageBulkExportJob = await PageBulkExportJob.create({
+        user: currentUser,
+        page: basePage,
+        uploadId: multipartUploader.uploadId,
+        format: PageBulkExportFormat.markdown,
+      });
+      await Subscription.upsertSubscription(currentUser, SupportedTargetModel.MODEL_PAGE_BULK_EXPORT_JOB, pageBulkExportJob, SubscriptionStatusType.SUBSCRIBE);
     }
     catch (err) {
-      await this.handleExportError(err, multipartUploader);
-      return;
+      logger.error(err);
+      await multipartUploader.abortUpload();
+      throw err;
     }
-    const multipartUploadWritable = this.getMultipartUploadWritable(multipartUploader);
+
+    const multipartUploadWritable = this.getMultipartUploadWritable(multipartUploader, pageBulkExportJob, attachment, activityParameters);
 
     // Cannot directly pipe from pagesWritable to zipArchiver due to how the 'append' method works.
     // Hence, execution of two pipelines is required.
-    pipeline(pagesReadable, pagesWritable, err => this.handleExportError(err, multipartUploader));
-    pipeline(zipArchiver, bufferToPartSizeTransform, multipartUploadWritable, err => this.handleExportError(err, multipartUploader));
+    pipeline(pagesReadable, pagesWritable, err => this.handleExportErrorInStream(err, activityParameters, pageBulkExportJob, multipartUploader));
+    pipeline(zipArchiver, bufferToPartSizeTransform, multipartUploadWritable,
+      err => this.handleExportErrorInStream(err, activityParameters, pageBulkExportJob, multipartUploader));
   }
 
-  async handleExportError(err: Error | null, multipartUploader: IAwsMultipartUploader | undefined): Promise<void> {
+  private async handleExportErrorInStream(
+      err: Error | null, activityParameters: ActivityParameters, pageBulkExportJob: PageBulkExportJobDocument, multipartUploader: IAwsMultipartUploader,
+  ): Promise<void> {
     if (err != null) {
       logger.error(err);
-      if (multipartUploader != null) {
-        await multipartUploader.abortUpload();
-      }
-      // TODO: notify failure to client: https://redmine.weseek.co.jp/issues/78037
+      await multipartUploader.abortUpload();
+      await this.notifyExportResult(activityParameters, pageBulkExportJob, SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED);
     }
   }
 
@@ -143,7 +183,12 @@ class PageBulkExportService {
     return zipArchiver;
   }
 
-  private getMultipartUploadWritable(multipartUploader: IAwsMultipartUploader): Writable {
+  private getMultipartUploadWritable(
+      multipartUploader: IAwsMultipartUploader,
+      pageBulkExportJob: PageBulkExportJobDocument,
+      attachment: IAttachmentDocument,
+      activityParameters: ActivityParameters,
+  ): Writable {
     let partNumber = 1;
 
     return new Writable({
@@ -161,9 +206,19 @@ class PageBulkExportService {
         }
         callback();
       },
-      async final(callback) {
+      final: async(callback) => {
         try {
           await multipartUploader.completeUpload();
+
+          const fileSize = await multipartUploader.getUploadedFileSize();
+          attachment.fileSize = fileSize;
+          await attachment.save();
+
+          pageBulkExportJob.completedAt = new Date();
+          pageBulkExportJob.attachment = attachment._id;
+          await pageBulkExportJob.save();
+
+          await this.notifyExportResult(activityParameters, pageBulkExportJob, SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED);
         }
         catch (err) {
           callback(err);
@@ -174,6 +229,24 @@ class PageBulkExportService {
     });
   }
 
+  private async notifyExportResult(
+      activityParameters: ActivityParameters, pageBulkExportJob: PageBulkExportJobDocument, action: SupportedActionType,
+  ) {
+    const activity = await this.crowi.activityService.createActivity({
+      ...activityParameters,
+      action,
+      targetModel: SupportedTargetModel.MODEL_PAGE_BULK_EXPORT_JOB,
+      target: pageBulkExportJob,
+      user: pageBulkExportJob.user,
+      snapshot: {
+        username: isPopulated(pageBulkExportJob.user) ? pageBulkExportJob.user.username : '',
+      },
+    });
+    const getAdditionalTargetUsers = (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

+ 8 - 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,8 @@ 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_TAG_UPDATE = 'TAG_UPDATE';
 const ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN = 'IN_APP_NOTIFICATION_ALL_STATUSES_OPEN';
 const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
@@ -166,6 +169,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 = {
@@ -340,6 +344,8 @@ 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,
 } as const;
 
 // Action required for notification
@@ -358,6 +364,8 @@ 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,
 } as const;
 
 export const ActionGroupSize = {

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

+ 11 - 7
apps/app/src/server/models/activity.ts

@@ -1,13 +1,17 @@
-import type { Ref, IPage } from '@growi/core';
-import {
-  Types, Document, Model, Schema, SortOrder,
+import type { Ref, IPage, IUser } from '@growi/core';
+import type {
+  Types, Document, Model, SortOrder,
 } from 'mongoose';
+import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
+import type {
+  IActivity, ISnapshot, SupportedActionType, SupportedTargetModelType, SupportedEventModelType,
+} from '~/interfaces/activity';
 import {
-  IActivity, ISnapshot, AllSupportedActions, SupportedActionType,
-  AllSupportedTargetModels, SupportedTargetModelType,
-  AllSupportedEventModels, SupportedEventModelType,
+  AllSupportedActions,
+  AllSupportedTargetModels,
+  AllSupportedEventModels,
 } from '~/interfaces/activity';
 
 import loggerFactory from '../../utils/logger';
@@ -21,7 +25,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>) {

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

@@ -35,7 +35,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();
     }

+ 14 - 10
apps/app/src/server/service/file-uploader/aws/index.ts

@@ -1,5 +1,3 @@
-import { Writable } from 'stream';
-
 import {
   S3Client,
   HeadObjectCommand,
@@ -10,14 +8,13 @@ import {
   ListObjectsCommand,
   type GetObjectCommandInput,
   ObjectCannedACL,
-  CreateMultipartUploadCommand,
-  UploadPartCommand,
-  CompleteMultipartUploadCommand,
 } from '@aws-sdk/client-s3';
 import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 import urljoin from 'url-join';
 
-import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
+import {
+  AttachmentType, FilePathOnStoragePrefix, ResponseMode, type RespondOptions,
+} from '~/server/interfaces/attachment';
 import type { IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
@@ -84,14 +81,21 @@ const S3Factory = (): S3Client => {
   return new S3Client(config);
 };
 
-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;

+ 23 - 2
apps/app/src/server/service/file-uploader/aws/multipart-upload.ts

@@ -1,5 +1,6 @@
 import {
   CreateMultipartUploadCommand, UploadPartCommand, type S3Client, CompleteMultipartUploadCommand, AbortMultipartUploadCommand,
+  HeadObjectCommand,
 } from '@aws-sdk/client-s3';
 
 import loggerFactory from '~/utils/logger';
@@ -19,6 +20,8 @@ export interface IAwsMultipartUploader {
   uploadPart(body: Buffer, partNumber: number): Promise<void>;
   completeUpload(): Promise<void>;
   abortUpload(): Promise<void>;
+  uploadId: string | undefined;
+  getUploadedFileSize(): Promise<number>;
 }
 
 /**
@@ -33,7 +36,7 @@ export class AwsMultipartUploader implements IAwsMultipartUploader {
 
   private uploadKey: string;
 
-  private uploadId: string | undefined;
+  private _uploadId: string | undefined;
 
   private s3Client: S3Client;
 
@@ -41,12 +44,18 @@ export class AwsMultipartUploader implements IAwsMultipartUploader {
 
   private currentStatus: UploadStatus = UploadStatus.BEFORE_INIT;
 
+  private _uploadedFileSize: number | undefined;
+
   constructor(s3Client: S3Client, bucket: string, uploadKey: string) {
     this.s3Client = s3Client;
     this.bucket = bucket;
     this.uploadKey = uploadKey;
   }
 
+  get uploadId(): string | undefined {
+    return this._uploadId;
+  }
+
   async initUpload(): Promise<void> {
     this.validateUploadStatus(UploadStatus.BEFORE_INIT);
 
@@ -54,7 +63,7 @@ export class AwsMultipartUploader implements IAwsMultipartUploader {
       Bucket: this.bucket,
       Key: this.uploadKey,
     }));
-    this.uploadId = response.UploadId;
+    this._uploadId = response.UploadId;
     this.currentStatus = UploadStatus.IN_PROGRESS;
     logger.info(`Multipart upload initialized. Upload key: ${this.uploadKey}`);
   }
@@ -103,6 +112,18 @@ export class AwsMultipartUploader implements IAwsMultipartUploader {
     logger.info(`Multipart upload aborted. Upload key: ${this.uploadKey}`);
   }
 
+  async getUploadedFileSize(): Promise<number> {
+    if (this._uploadedFileSize != null) return this._uploadedFileSize;
+
+    this.validateUploadStatus(UploadStatus.COMPLETED);
+    const headData = await this.s3Client.send(new HeadObjectCommand({
+      Bucket: this.bucket,
+      Key: this.uploadKey,
+    }));
+    this._uploadedFileSize = headData.ContentLength;
+    return this._uploadedFileSize ?? 0;
+  }
+
   private validateUploadStatus(desiredStatus: UploadStatus): void {
     if (desiredStatus === this.currentStatus) return;
 

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

@@ -1,11 +1,14 @@
-import { ClientSecretCredential, TokenCredential } from '@azure/identity';
-import {
-  generateBlobSASQueryParameters,
-  BlobServiceClient,
+import type { TokenCredential } from '@azure/identity';
+import { ClientSecretCredential } from '@azure/identity';
+import type {
   BlobClient,
   BlockBlobClient,
   BlobDeleteOptions,
   ContainerClient,
+} from '@azure/storage-blob';
+import {
+  generateBlobSASQueryParameters,
+  BlobServiceClient,
   ContainerSASPermissions,
   SASProtocol,
   type BlobDeleteIfExistsResponse,
@@ -13,7 +16,7 @@ import {
   type BlockBlobParallelUploadOptions,
 } from '@azure/storage-blob';
 
-import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
+import { FilePathOnStoragePrefix, ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
 import type { IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
@@ -59,8 +62,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);
 }
 

+ 14 - 5
apps/app/src/server/service/file-uploader/gcs.ts

@@ -2,7 +2,9 @@ import { Storage } from '@google-cloud/storage';
 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';
 import loggerFactory from '~/utils/logger';
 
@@ -32,12 +34,19 @@ function getGcsInstance() {
   return storage;
 }
 
-function getFilePathOnStorage(attachment) {
+function getFilePathOnStorage(attachment: IAttachmentDocument) {
   const namespace = configManager.getConfig('crowi', 'gcs:uploadNamespace');
   // const namespace = null;
-  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;

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

@@ -2,7 +2,7 @@ import { Readable } from 'stream';
 
 import type { Response } from 'express';
 
-import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
+import { FilePathOnStoragePrefix, ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
 import type { IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
@@ -99,10 +99,10 @@ module.exports = function(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;

+ 3 - 2
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';
@@ -50,7 +51,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) {
@@ -198,7 +199,7 @@ 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);
 

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

@@ -1,15 +1,16 @@
 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';
 
-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) => {
+export const generateSnapshot = (targetModel: string, target: IUser | IPage | IPageBulkExportJob): string | undefined => {
 
-  let snapshot;
+  let snapshot: string | undefined;
 
   if (isIPage(targetModel, target)) {
     snapshot = pageSerializers.stringifySnapshot(target);

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

@@ -1,8 +1,9 @@
-import type {
-  IPage, IUser, Ref,
+import {
+  getIdForRef,
+  type IPage, type IUser, type Ref,
 } from '@growi/core';
 
-import { ActivityDocument } from '../models/activity';
+import type { ActivityDocument } from '../models/activity';
 import Subscription from '../models/subscription';
 import { getModelSafely } from '../util/mongoose-utils';
 
@@ -38,7 +39,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,

+ 2 - 0
packages/core/src/interfaces/attachment.ts

@@ -6,12 +6,14 @@ import type { IUser } from './user';
 export type IAttachment = {
   page?: Ref<IPage>,
   creator?: Ref<IUser>,
+  filePath?: string, // DEPRECATED: remains for backward compatibility for v3.3.x or below
   fileName: string,
   fileFormat: string,
   fileSize: number,
   originalName: string,
   temporaryUrlCached?: string,
   temporaryUrlExpiredAt?: Date,
+  attachmentType: string,
 
   createdAt: Date,