Explorar el Código

add ResponseMode enum

Yuki Takei hace 2 años
padre
commit
0e4cdc57fd

+ 56 - 20
apps/app/src/server/routes/attachment/get.ts

@@ -9,6 +9,7 @@ import mongoose from 'mongoose';
 
 import type { CrowiProperties, CrowiRequest } from '~/interfaces/crowi-request';
 import type { ExpressHttpHeader, RespondOptions } from '~/server/interfaces/attachment';
+import { FileUploader, ResponseMode } from '~/server/service/file-uploader/file-uploader';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../../crowi';
@@ -79,6 +80,50 @@ export const generateHeadersForFresh = (attachment: IAttachmentDocument): Expres
 };
 
 
+const respondForRedirectMode = async(res: Response, fileUploadService: FileUploader, attachment: IAttachmentDocument, opts?: RespondOptions): Promise<void> => {
+  const isDownload = opts?.download ?? false;
+
+  if (!isDownload) {
+    const temporaryUrl = attachment.getValidTemporaryUrl();
+    if (temporaryUrl != null) {
+      res.redirect(temporaryUrl);
+      return;
+    }
+  }
+
+  const temporaryUrl = await fileUploadService.generateTemporaryUrl(attachment, opts);
+
+  res.redirect(temporaryUrl.url);
+
+  // persist temporaryUrl
+  if (!isDownload) {
+    try {
+      attachment.cashTemporaryUrlByProvideSec(temporaryUrl.url, temporaryUrl.lifetimeSec);
+      return;
+    }
+    catch (err) {
+      logger.error(err);
+    }
+  }
+};
+
+const respondForRelayMode = async(res: Response, fileUploadService: FileUploader, attachment: IAttachmentDocument, opts?: RespondOptions): Promise<void> => {
+  // apply content-* headers before response
+  const isDownload = opts?.download ?? false;
+  const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+  applyHeaders(res, contentHeaders.toExpressHttpHeaders());
+
+  try {
+    const readable = await fileUploadService.findDeliveryFile(attachment);
+    readable.pipe(res);
+  }
+  catch (e) {
+    logger.error(e);
+    res.json(ApiResponse.error(e.message));
+    return;
+  }
+};
+
 export const getActionFactory = (crowi: Crowi, attachment: IAttachmentDocument) => {
   return async(req: CrowiRequest, res: Response, opts?: RespondOptions): Promise<void> => {
 
@@ -94,27 +139,18 @@ export const getActionFactory = (crowi: Crowi, attachment: IAttachmentDocument)
 
     const { fileUploadService } = crowi;
 
-    if (fileUploadService.shouldDelegateToResponse()) {
-      fileUploadService.respond(res, attachment, opts);
-      return;
-    }
-
-    // apply content-* headers before response
-    const isDownload = opts?.download ?? false;
-    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
-    applyHeaders(res, contentHeaders.toExpressHttpHeaders());
-
-    try {
-      const readable = await fileUploadService.findDeliveryFile(attachment);
-      readable.pipe(res);
+    const responseMode = fileUploadService.determineResponseMode();
+    switch (responseMode) {
+      case ResponseMode.DELEGATE:
+        fileUploadService.respond(res, attachment, opts);
+        return;
+      case ResponseMode.REDIRECT:
+        respondForRedirectMode(res, fileUploadService, attachment, opts);
+        return;
+      case ResponseMode.RELAY:
+        respondForRelayMode(res, fileUploadService, attachment, opts);
+        return;
     }
-    catch (e) {
-      logger.error(e);
-      res.json(ApiResponse.error(e.message));
-      return;
-    }
-
-    return;
   };
 };
 

+ 107 - 113
apps/app/src/server/service/file-uploader/aws.ts

@@ -9,7 +9,6 @@ import {
   type GetObjectCommandInput,
 } from '@aws-sdk/client-s3';
 import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
-import type { Response } from 'express';
 import urljoin from 'url-join';
 
 import type { RespondOptions } from '~/server/interfaces/attachment';
@@ -18,7 +17,9 @@ import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';
 
-import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+import {
+  AbstractFileUploader, ResponseMode, type TemporaryUrl, type SaveFileParam,
+} from './file-uploader';
 import { ContentHeaders } from './utils';
 
 
@@ -45,6 +46,51 @@ type AwsConfig = {
   forcePathStyle?: boolean
 }
 
+const isFileExists = async(s3: S3Client, params) => {
+  try {
+    await s3.send(new HeadObjectCommand(params));
+  }
+  catch (err) {
+    if (err != null && err.code === 'NotFound') {
+      return false;
+    }
+    throw err;
+  }
+  return true;
+};
+
+const getAwsConfig = (): AwsConfig => {
+  return {
+    credentials: {
+      accessKeyId: configManager.getConfig('crowi', 'aws:s3AccessKeyId'),
+      secretAccessKey: configManager.getConfig('crowi', 'aws:s3SecretAccessKey'),
+    },
+    region: configManager.getConfig('crowi', 'aws:s3Region'),
+    endpoint: configManager.getConfig('crowi', 'aws:s3CustomEndpoint'),
+    bucket: configManager.getConfig('crowi', 'aws:s3Bucket'),
+    forcePathStyle: configManager.getConfig('crowi', 'aws:s3CustomEndpoint') != null, // s3ForcePathStyle renamed to forcePathStyle in v3
+  };
+};
+
+const S3Factory = (): S3Client => {
+  const config = getAwsConfig();
+  return new S3Client(config);
+};
+
+const getFilePathOnStorage = (attachment) => {
+  if (attachment.filePath != null) {
+    return attachment.filePath;
+  }
+
+  const dirName = (attachment.page != null)
+    ? 'attachment'
+    : 'user';
+  const filePath = urljoin(dirName, attachment.fileName);
+
+  return filePath;
+};
+
+
 // TODO: rewrite this module to be a type-safe implementation
 class AwsFileUploader extends AbstractFileUploader {
 
@@ -79,94 +125,72 @@ class AwsFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void {
-    throw new Error('Method not implemented.');
+  override determineResponseMode() {
+    return configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode')
+      ? ResponseMode.RELAY
+      : ResponseMode.REDIRECT;
   }
 
   /**
    * @inheritdoc
    */
-  override findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
-    throw new Error('Method not implemented.');
+  override respond(): void {
+    throw new Error('AwsFileUploader does not support ResponseMode.DELEGATE.');
   }
 
-}
+  /**
+   * @inheritdoc
+   */
+  override async findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
+    if (!this.getIsReadable()) {
+      throw new Error('AWS is not configured.');
+    }
 
-module.exports = (crowi) => {
-  const lib = new AwsFileUploader(crowi);
+    const s3 = S3Factory();
+    const awsConfig = getAwsConfig();
+    const filePath = getFilePathOnStorage(attachment);
 
-  const getAwsConfig = (): AwsConfig => {
-    return {
-      credentials: {
-        accessKeyId: configManager.getConfig('crowi', 'aws:s3AccessKeyId'),
-        secretAccessKey: configManager.getConfig('crowi', 'aws:s3SecretAccessKey'),
-      },
-      region: configManager.getConfig('crowi', 'aws:s3Region'),
-      endpoint: configManager.getConfig('crowi', 'aws:s3CustomEndpoint'),
-      bucket: configManager.getConfig('crowi', 'aws:s3Bucket'),
-      forcePathStyle: configManager.getConfig('crowi', 'aws:s3CustomEndpoint') != null, // s3ForcePathStyle renamed to forcePathStyle in v3
+    const params = {
+      Bucket: awsConfig.bucket,
+      Key: filePath,
     };
-  };
-
-  const S3Factory = (): S3Client => {
-    const config = getAwsConfig();
-    return new S3Client(config);
-  };
 
-  const getFilePathOnStorage = (attachment) => {
-    if (attachment.filePath != null) {
-      return attachment.filePath;
+    // check file exists
+    const isExists = await isFileExists(s3, params);
+    if (!isExists) {
+      throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
     }
 
-    const dirName = (attachment.page != null)
-      ? 'attachment'
-      : 'user';
-    const filePath = urljoin(dirName, attachment.fileName);
+    try {
+      const body = (await s3.send(new GetObjectCommand(params))).Body;
 
-    return filePath;
-  };
+      if (body == null) {
+        throw new Error(`S3 returned null for the Attachment (${filePath})`);
+      }
 
-  const isFileExists = async(s3: S3Client, params) => {
-    try {
-      await s3.send(new HeadObjectCommand(params));
+      // eslint-disable-next-line no-nested-ternary
+      return 'stream' in body
+        ? body.stream() // get stream from Blob
+        : !('read' in body)
+          ? body as unknown as NodeJS.ReadableStream // cast force
+          : body;
     }
     catch (err) {
-      if (err != null && err.code === 'NotFound') {
-        return false;
-      }
-      throw err;
+      logger.error(err);
+      throw new Error(`Coudn't get file from AWS for the Attachment (${attachment._id.toString()})`);
     }
-    return true;
-  };
-
-  lib.isValidUploadSettings = function() {
-    return configManager.getConfig('crowi', 'aws:s3AccessKeyId') != null
-      && configManager.getConfig('crowi', 'aws:s3SecretAccessKey') != null
-      && (
-        configManager.getConfig('crowi', 'aws:s3Region') != null
-          || configManager.getConfig('crowi', 'aws:s3CustomEndpoint') != null
-      )
-      && configManager.getConfig('crowi', 'aws:s3Bucket') != null;
-  };
-
-  lib.shouldDelegateToResponse = function() {
-    return !configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
-  };
+  }
 
-  lib.respond = async function(res, attachment, opts?) {
-    if (!lib.getIsUploadable()) {
+  /**
+   * @inheritDoc
+   */
+  override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
+    if (!this.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
 
     const isDownload = opts?.download ?? false;
 
-    if (!isDownload) {
-      const temporaryUrl = attachment.getValidTemporaryUrl();
-      if (temporaryUrl != null) {
-        return res.redirect(temporaryUrl);
-      }
-    }
-
     const s3 = S3Factory();
     const awsConfig = getAwsConfig();
     const filePath = getFilePathOnStorage(attachment);
@@ -186,16 +210,26 @@ module.exports = (crowi) => {
       expiresIn: lifetimeSecForTemporaryUrl,
     });
 
+    return {
+      url: signedUrl,
+      lifetimeSec: lifetimeSecForTemporaryUrl,
+    };
 
-    res.redirect(signedUrl);
+  }
 
-    try {
-      return attachment.cashTemporaryUrlByProvideSec(signedUrl, lifetimeSecForTemporaryUrl);
-    }
-    catch (err) {
-      logger.error(err);
-    }
+}
 
+module.exports = (crowi) => {
+  const lib = new AwsFileUploader(crowi);
+
+  lib.isValidUploadSettings = function() {
+    return configManager.getConfig('crowi', 'aws:s3AccessKeyId') != null
+      && configManager.getConfig('crowi', 'aws:s3SecretAccessKey') != null
+      && (
+        configManager.getConfig('crowi', 'aws:s3Region') != null
+          || configManager.getConfig('crowi', 'aws:s3CustomEndpoint') != null
+      )
+      && configManager.getConfig('crowi', 'aws:s3Bucket') != null;
   };
 
   (lib as any).deleteFile = async function(attachment) {
@@ -280,46 +314,6 @@ module.exports = (crowi) => {
     return s3.send(new PutObjectCommand(params));
   };
 
-  lib.findDeliveryFile = async function(attachment) {
-    if (!lib.getIsReadable()) {
-      throw new Error('AWS is not configured.');
-    }
-
-    const s3 = S3Factory();
-    const awsConfig = getAwsConfig();
-    const filePath = getFilePathOnStorage(attachment);
-
-    const params = {
-      Bucket: awsConfig.bucket,
-      Key: filePath,
-    };
-
-    // check file exists
-    const isExists = await isFileExists(s3, params);
-    if (!isExists) {
-      throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
-    }
-
-    try {
-      const body = (await s3.send(new GetObjectCommand(params))).Body;
-
-      if (body == null) {
-        throw new Error(`S3 returned null for the Attachment (${filePath})`);
-      }
-
-      // eslint-disable-next-line no-nested-ternary
-      return 'stream' in body
-        ? body.stream() // get stream from Blob
-        : !('read' in body)
-          ? body as unknown as NodeJS.ReadableStream // cast force
-          : body;
-    }
-    catch (err) {
-      logger.error(err);
-      throw new Error(`Coudn't get file from AWS for the Attachment (${attachment._id.toString()})`);
-    }
-  };
-
   (lib as any).checkLimit = async function(uploadFileSize) {
     const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
     const totalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');

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

@@ -6,16 +6,15 @@ import {
   BlobClient,
   BlockBlobClient,
   BlobDeleteOptions,
-  BlobDeleteIfExistsResponse,
-  BlockBlobUploadResponse,
   ContainerClient,
   generateBlobSASQueryParameters,
   ContainerSASPermissions,
   SASProtocol,
-  BlockBlobParallelUploadOptions,
-  BlockBlobUploadStreamOptions,
+  type BlobDeleteIfExistsResponse,
+  type BlockBlobUploadResponse,
+  type BlockBlobParallelUploadOptions,
+  type BlockBlobUploadStreamOptions,
 } from '@azure/storage-blob';
-import type { Response } from 'express';
 
 import type { RespondOptions } from '~/server/interfaces/attachment';
 import type { IAttachmentDocument } from '~/server/models';
@@ -23,7 +22,10 @@ import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';
 
-import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+import {
+  AbstractFileUploader, ResponseMode, type TemporaryUrl, type SaveFileParam,
+} from './file-uploader';
+import { ContentHeaders } from './utils';
 
 const urljoin = require('url-join');
 
@@ -39,6 +41,59 @@ type AzureConfig = {
   containerName: string,
 }
 
+
+function getAzureConfig(): AzureConfig {
+  return {
+    accountName: configManager.getConfig('crowi', 'azure:storageAccountName'),
+    containerName: configManager.getConfig('crowi', 'azure:storageContainerName'),
+  };
+}
+
+function getCredential(): TokenCredential {
+  const tenantId = configManager.getConfig('crowi', 'azure:tenantId');
+  const clientId = configManager.getConfig('crowi', 'azure:clientId');
+  const clientSecret = configManager.getConfig('crowi', 'azure:clientSecret');
+  return new ClientSecretCredential(tenantId, clientId, clientSecret);
+}
+
+async function getContainerClient(): Promise<ContainerClient> {
+  const { accountName, containerName } = getAzureConfig();
+  const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
+  return blobServiceClient.getContainerClient(containerName);
+}
+
+// Server creates User Delegation SAS Token for container
+// https://learn.microsoft.com/ja-jp/azure/storage/blobs/storage-blob-create-user-delegation-sas-javascript
+async function getSasToken(lifetimeSec) {
+  const { accountName, containerName } = getAzureConfig();
+  const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
+
+  const now = Date.now();
+  const startsOn = new Date(now - 30 * 1000);
+  const expiresOn = new Date(now + lifetimeSec * 1000);
+  const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);
+
+  // https://github.com/Azure/azure-sdk-for-js/blob/d4d55f73/sdk/storage/storage-blob/src/ContainerSASPermissions.ts#L24
+  // r:read, a:add, c:create, w:write, d:delete, l:list
+  const containerPermissionsForAnonymousUser = 'rl';
+  const sasOptions = {
+    containerName,
+    permissions: ContainerSASPermissions.parse(containerPermissionsForAnonymousUser),
+    protocol: SASProtocol.HttpsAndHttp,
+    startsOn,
+    expiresOn,
+  };
+
+  const sasToken = generateBlobSASQueryParameters(sasOptions, userDelegationKey, accountName).toString();
+
+  return sasToken;
+}
+
+function getFilePathOnStorage(attachment) {
+  const dirName = (attachment.page != null) ? 'attachment' : 'user';
+  return urljoin(dirName, attachment.fileName);
+}
+
 class AzureFileUploader extends AbstractFileUploader {
 
   /**
@@ -72,97 +127,52 @@ class AzureFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void {
-    throw new Error('Method not implemented.');
+  override determineResponseMode() {
+    return configManager.getConfig('crowi', 'azure:referenceFileWithRelayMode')
+      ? ResponseMode.RELAY
+      : ResponseMode.REDIRECT;
   }
 
   /**
    * @inheritdoc
    */
-  override findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
-    throw new Error('Method not implemented.');
-  }
-
-}
-
-module.exports = (crowi) => {
-  const lib = new AzureFileUploader(crowi);
-
-  function getAzureConfig(): AzureConfig {
-    return {
-      accountName: configManager.getConfig('crowi', 'azure:storageAccountName'),
-      containerName: configManager.getConfig('crowi', 'azure:storageContainerName'),
-    };
-  }
-
-  function getCredential(): TokenCredential {
-    const tenantId = configManager.getConfig('crowi', 'azure:tenantId');
-    const clientId = configManager.getConfig('crowi', 'azure:clientId');
-    const clientSecret = configManager.getConfig('crowi', 'azure:clientSecret');
-    return new ClientSecretCredential(tenantId, clientId, clientSecret);
-  }
-
-  async function getContainerClient(): Promise<ContainerClient> {
-    const { accountName, containerName } = getAzureConfig();
-    const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
-    return blobServiceClient.getContainerClient(containerName);
+  override respond(): void {
+    throw new Error('AzureFileUploader does not support ResponseMode.DELEGATE.');
   }
 
-  // Server creates User Delegation SAS Token for container
-  // https://learn.microsoft.com/ja-jp/azure/storage/blobs/storage-blob-create-user-delegation-sas-javascript
-  async function getSasToken(lifetimeSec) {
-    const { accountName, containerName } = getAzureConfig();
-    const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
-
-    const now = Date.now();
-    const startsOn = new Date(now - 30 * 1000);
-    const expiresOn = new Date(now + lifetimeSec * 1000);
-    const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);
-
-    // https://github.com/Azure/azure-sdk-for-js/blob/d4d55f73/sdk/storage/storage-blob/src/ContainerSASPermissions.ts#L24
-    // r:read, a:add, c:create, w:write, d:delete, l:list
-    const containerPermissionsForAnonymousUser = 'rl';
-    const sasOptions = {
-      containerName,
-      permissions: ContainerSASPermissions.parse(containerPermissionsForAnonymousUser),
-      protocol: SASProtocol.HttpsAndHttp,
-      startsOn,
-      expiresOn,
-    };
-
-    const sasToken = generateBlobSASQueryParameters(sasOptions, userDelegationKey, accountName).toString();
+  /**
+   * @inheritdoc
+   */
+  override async findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
+    if (!this.getIsReadable()) {
+      throw new Error('Azure is not configured.');
+    }
 
-    return sasToken;
-  }
+    const filePath = getFilePathOnStorage(attachment);
+    const containerClient = await getContainerClient();
+    const blobClient: BlobClient = containerClient.getBlobClient(filePath);
+    const downloadResponse = await blobClient.download();
+    if (downloadResponse.errorCode) {
+      logger.error(downloadResponse.errorCode);
+      throw new Error(downloadResponse.errorCode);
+    }
+    if (!downloadResponse?.readableStreamBody) {
+      throw new Error(`Coudn't get file from Azure for the Attachment (${filePath})`);
+    }
 
-  function getFilePathOnStorage(attachment) {
-    const dirName = (attachment.page != null) ? 'attachment' : 'user';
-    return urljoin(dirName, attachment.fileName);
+    return downloadResponse.readableStreamBody;
   }
 
-  lib.isValidUploadSettings = function() {
-    return configManager.getConfig('crowi', 'azure:storageAccountName') != null
-      && configManager.getConfig('crowi', 'azure:storageContainerName') != null;
-  };
-
-  lib.shouldDelegateToResponse = function() {
-    return !configManager.getConfig('crowi', 'azure:referenceFileWithRelayMode');
-  };
-
-  lib.respond = async function(res, attachment, opts) {
-    if (!lib.getIsUploadable()) {
+  /**
+   * @inheritDoc
+   */
+  override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
+    if (!this.getIsUploadable()) {
       throw new Error('Azure Blob is not configured.');
     }
 
     const isDownload = opts?.download ?? false;
 
-    if (!isDownload) {
-      const temporaryUrl = attachment.getValidTemporaryUrl();
-      if (temporaryUrl != null) {
-        return res.redirect(temporaryUrl);
-      }
-    }
-
     const containerClient = await getContainerClient();
     const filePath = getFilePathOnStorage(attachment);
     const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
@@ -172,21 +182,27 @@ module.exports = (crowi) => {
     const signedUrl = `${blockBlobClient.url}?${sasToken}`;
 
     // TODO: re-impl using generateSasUrl
-    // const contentHeaders = new ContentHeaders(attachment, { inline: true });
+    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
     // const signedUrl = blockBlobClient.generateSasUrl({
     //   contentType: contentHeaders.contentType?.value.toString(),
     //   contentDisposition: contentHeaders.contentDisposition?.value.toString(),
     // });
 
-    res.redirect(signedUrl);
+    return {
+      url: signedUrl,
+      lifetimeSec: lifetimeSecForTemporaryUrl,
+    };
 
-    try {
-      return attachment.cashTemporaryUrlByProvideSec(signedUrl, lifetimeSecForTemporaryUrl);
-    }
-    catch (err) {
-      logger.error(err);
-    }
+  }
+
+}
+
+module.exports = (crowi) => {
+  const lib = new AzureFileUploader(crowi);
 
+  lib.isValidUploadSettings = function() {
+    return configManager.getConfig('crowi', 'azure:storageAccountName') != null
+      && configManager.getConfig('crowi', 'azure:storageContainerName') != null;
   };
 
   (lib as any).deleteFile = async function(attachment) {
@@ -243,27 +259,6 @@ module.exports = (crowi) => {
     return;
   };
 
-  lib.findDeliveryFile = async function(attachment) {
-    if (!lib.getIsReadable()) {
-      throw new Error('Azure is not configured.');
-    }
-
-    const filePath = getFilePathOnStorage(attachment);
-    const containerClient = await getContainerClient();
-    const blobClient: BlobClient = containerClient.getBlobClient(filePath);
-    const downloadResponse = await blobClient.download();
-    if (downloadResponse.errorCode) {
-      logger.error(downloadResponse.errorCode);
-      throw new Error(downloadResponse.errorCode);
-    }
-    if (!downloadResponse?.readableStreamBody) {
-      throw new Error(`Coudn't get file from Azure for the Attachment (${filePath})`);
-    }
-
-    return downloadResponse.readableStreamBody;
-  };
-
-
   (lib as any).checkLimit = async function(uploadFileSize) {
     const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
     const gcsTotalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');

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

@@ -22,6 +22,19 @@ export type CheckLimitResult = {
   errorMessage?: string,
 }
 
+export const ResponseMode = {
+  RELAY: 'relay',
+  REDIRECT: 'redirect',
+  DELEGATE: 'delegate',
+} as const;
+
+export type ResponseMode = typeof ResponseMode[keyof typeof ResponseMode];
+
+export type TemporaryUrl = {
+  url: string,
+  lifetimeSec: number,
+}
+
 export interface FileUploader {
   getIsUploadable(): boolean,
   isWritable(): Promise<boolean>,
@@ -34,9 +47,10 @@ export interface FileUploader {
   getFileUploadTotalLimit(): number,
   getTotalFileSize(): Promise<number>,
   doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<CheckLimitResult>,
-  shouldDelegateToResponse(): boolean
+  determineResponseMode(): ResponseMode,
   respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void,
-  findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream>
+  findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream>,
+  generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl>,
 }
 
 export abstract class AbstractFileUploader implements FileUploader {
@@ -143,10 +157,10 @@ export abstract class AbstractFileUploader implements FileUploader {
   }
 
   /**
-   * Checks if Uploader can respond to the HTTP request.
+   * Determine ResponseMode
    */
-  shouldDelegateToResponse(): boolean {
-    return false;
+  determineResponseMode(): ResponseMode {
+    return ResponseMode.RELAY;
   }
 
   /**
@@ -159,4 +173,9 @@ export abstract class AbstractFileUploader implements FileUploader {
    */
   abstract findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream>;
 
+  /**
+   * Generate temporaryUrl that is valid for a very short time
+   */
+  abstract generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl>;
+
 }

+ 86 - 98
apps/app/src/server/service/file-uploader/gcs.ts

@@ -1,5 +1,4 @@
 import { Storage } from '@google-cloud/storage';
-import type { Response } from 'express';
 import urljoin from 'url-join';
 
 import type Crowi from '~/server/crowi';
@@ -9,12 +8,52 @@ import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';
 
-import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+import {
+  AbstractFileUploader, ResponseMode, type TemporaryUrl, type SaveFileParam,
+} from './file-uploader';
 import { ContentHeaders } from './utils';
 
 const logger = loggerFactory('growi:service:fileUploaderGcs');
 
 
+function getGcsBucket() {
+  return configManager.getConfig('crowi', 'gcs:bucket');
+}
+
+let storage: Storage;
+function getGcsInstance() {
+  if (storage == null) {
+    const keyFilename = configManager.getConfig('crowi', 'gcs:apiKeyJsonPath');
+    // see https://googleapis.dev/nodejs/storage/latest/Storage.html
+    storage = keyFilename != null
+      ? new Storage({ keyFilename }) // Create a client with explicit credentials
+      : new Storage(); // Create a client that uses Application Default Credentials
+  }
+  return storage;
+}
+
+function getFilePathOnStorage(attachment) {
+  const namespace = configManager.getConfig('crowi', 'gcs:uploadNamespace');
+  // const namespace = null;
+  const dirName = (attachment.page != null)
+    ? 'attachment'
+    : 'user';
+  const filePath = urljoin(namespace || '', dirName, attachment.fileName);
+
+  return filePath;
+}
+
+/**
+ * check file existence
+ * @param {File} file https://googleapis.dev/nodejs/storage/latest/File.html
+ */
+async function isFileExists(file) {
+  // check file exists
+  const res = await file.exists();
+  return res[0];
+}
+
+
 // TODO: rewrite this module to be a type-safe implementation
 class GcsFileUploader extends AbstractFileUploader {
 
@@ -49,84 +88,57 @@ class GcsFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void {
-    throw new Error('Method not implemented.');
+  override determineResponseMode() {
+    return configManager.getConfig('crowi', 'gcs:referenceFileWithRelayMode')
+      ? ResponseMode.RELAY
+      : ResponseMode.REDIRECT;
   }
 
   /**
    * @inheritdoc
    */
-  override findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
-    throw new Error('Method not implemented.');
+  override respond(): void {
+    throw new Error('GcsFileUploader does not support ResponseMode.DELEGATE.');
   }
 
-}
-
-
-let _instance: Storage;
-
-module.exports = function(crowi: Crowi) {
-  const lib = new GcsFileUploader(crowi);
+  /**
+   * @inheritdoc
+   */
+  override async findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
+    if (!this.getIsReadable()) {
+      throw new Error('GCS is not configured.');
+    }
 
-  function getGcsBucket() {
-    return configManager.getConfig('crowi', 'gcs:bucket');
-  }
+    const gcs = getGcsInstance();
+    const myBucket = gcs.bucket(getGcsBucket());
+    const filePath = getFilePathOnStorage(attachment);
+    const file = myBucket.file(filePath);
 
-  function getGcsInstance() {
-    if (_instance == null) {
-      const keyFilename = configManager.getConfig('crowi', 'gcs:apiKeyJsonPath');
-      // see https://googleapis.dev/nodejs/storage/latest/Storage.html
-      _instance = keyFilename != null
-        ? new Storage({ keyFilename }) // Create a client with explicit credentials
-        : new Storage(); // Create a client that uses Application Default Credentials
+    // check file exists
+    const isExists = await isFileExists(file);
+    if (!isExists) {
+      throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in GCS`);
     }
-    return _instance;
-  }
 
-  function getFilePathOnStorage(attachment) {
-    const namespace = configManager.getConfig('crowi', 'gcs:uploadNamespace');
-    // const namespace = null;
-    const dirName = (attachment.page != null)
-      ? 'attachment'
-      : 'user';
-    const filePath = urljoin(namespace || '', dirName, attachment.fileName);
-
-    return filePath;
+    try {
+      return file.createReadStream();
+    }
+    catch (err) {
+      logger.error(err);
+      throw new Error(`Coudn't get file from AWS for the Attachment (${attachment._id.toString()})`);
+    }
   }
 
   /**
-   * check file existence
-   * @param {File} file https://googleapis.dev/nodejs/storage/latest/File.html
+   * @inheritDoc
    */
-  async function isFileExists(file) {
-    // check file exists
-    const res = await file.exists();
-    return res[0];
-  }
-
-  lib.isValidUploadSettings = function() {
-    return configManager.getConfig('crowi', 'gcs:apiKeyJsonPath') != null
-      && configManager.getConfig('crowi', 'gcs:bucket') != null;
-  };
-
-  lib.shouldDelegateToResponse = function() {
-    return !configManager.getConfig('crowi', 'gcs:referenceFileWithRelayMode');
-  };
-
-  lib.respond = async function(res, attachment, opts) {
-    if (!lib.getIsUploadable()) {
+  override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
+    if (!this.getIsUploadable()) {
       throw new Error('GCS is not configured.');
     }
 
     const isDownload = opts?.download ?? false;
 
-    if (!isDownload) {
-      const temporaryUrl = attachment.getValidTemporaryUrl();
-      if (temporaryUrl != null) {
-        return res.redirect(temporaryUrl);
-      }
-    }
-
     const gcs = getGcsInstance();
     const myBucket = gcs.bucket(getGcsBucket());
     const filePath = getFilePathOnStorage(attachment);
@@ -143,15 +155,22 @@ module.exports = function(crowi: Crowi) {
       responseDisposition: contentHeaders.contentDisposition?.value.toString(),
     });
 
-    res.redirect(signedUrl);
+    return {
+      url: signedUrl,
+      lifetimeSec: lifetimeSecForTemporaryUrl,
+    };
 
-    try {
-      return attachment.cashTemporaryUrlByProvideSec(signedUrl, lifetimeSecForTemporaryUrl);
-    }
-    catch (err) {
-      logger.error(err);
-    }
+  }
 
+}
+
+
+module.exports = function(crowi: Crowi) {
+  const lib = new GcsFileUploader(crowi);
+
+  lib.isValidUploadSettings = function() {
+    return configManager.getConfig('crowi', 'gcs:apiKeyJsonPath') != null
+      && configManager.getConfig('crowi', 'gcs:bucket') != null;
   };
 
   (lib as any).deleteFile = function(attachment) {
@@ -207,37 +226,6 @@ module.exports = function(crowi: Crowi) {
     return myBucket.file(filePath).save(data, { resumable: false });
   };
 
-  /**
-   * Find data substance
-   *
-   * @param {Attachment} attachment
-   * @return {stream.Readable} readable stream
-   */
-  lib.findDeliveryFile = async function(attachment) {
-    if (!lib.getIsReadable()) {
-      throw new Error('GCS is not configured.');
-    }
-
-    const gcs = getGcsInstance();
-    const myBucket = gcs.bucket(getGcsBucket());
-    const filePath = getFilePathOnStorage(attachment);
-    const file = myBucket.file(filePath);
-
-    // check file exists
-    const isExists = await isFileExists(file);
-    if (!isExists) {
-      throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in GCS`);
-    }
-
-    try {
-      return file.createReadStream();
-    }
-    catch (err) {
-      logger.error(err);
-      throw new Error(`Coudn't get file from AWS for the Attachment (${attachment._id.toString()})`);
-    }
-  };
-
   /**
    * check the file size limit
    *

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

@@ -1,7 +1,6 @@
 import { Readable } from 'stream';
 import util from 'util';
 
-import type { Response } from 'express';
 import mongoose from 'mongoose';
 import { createModel } from 'mongoose-gridfs';
 
@@ -11,7 +10,7 @@ import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';
 
-import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+import { AbstractFileUploader, type TemporaryUrl, type SaveFileParam } from './file-uploader';
 
 const logger = loggerFactory('growi:service:fileUploaderGridfs');
 
@@ -50,8 +49,8 @@ class GridfsFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void {
-    throw new Error('Method not implemented.');
+  override respond(): void {
+    throw new Error('GridfsFileUploader does not support ResponseMode.DELEGATE.');
   }
 
   /**
@@ -61,6 +60,13 @@ class GridfsFileUploader extends AbstractFileUploader {
     throw new Error('Method not implemented.');
   }
 
+  /**
+   * @inheritDoc
+   */
+  override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
+    throw new Error('GridfsFileUploader does not support ResponseMode.REDIRECT.');
+  }
+
 }
 
 

+ 0 - 1
apps/app/src/server/service/file-uploader/index.ts

@@ -12,7 +12,6 @@ const logger = loggerFactory('growi:service:FileUploaderServise');
 const envToModuleMappings = {
   aws:     'aws',
   local:   'local',
-  none:    'none',
   mongo:   'gridfs',
   mongodb: 'gridfs',
   gridfs:  'gridfs',

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

@@ -8,7 +8,9 @@ import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';
 
-import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+import {
+  AbstractFileUploader, ResponseMode, type TemporaryUrl, type SaveFileParam,
+} from './file-uploader';
 import {
   ContentHeaders, applyHeaders,
 } from './utils';
@@ -60,6 +62,15 @@ class LocalFileUploader extends AbstractFileUploader {
     throw new Error('Method not implemented.');
   }
 
+  /**
+   * @inheritdoc
+   */
+  override determineResponseMode() {
+    return configManager.getConfig('crowi', 'fileUpload:local:useInternalRedirect')
+      ? ResponseMode.DELEGATE
+      : ResponseMode.RELAY;
+  }
+
   /**
    * @inheritdoc
    */
@@ -74,6 +85,13 @@ class LocalFileUploader extends AbstractFileUploader {
     throw new Error('Method not implemented.');
   }
 
+  /**
+   * @inheritDoc
+   */
+  override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
+    throw new Error('LocalFileUploader does not support ResponseMode.REDIRECT.');
+  }
+
 }
 
 module.exports = function(crowi) {
@@ -188,14 +206,6 @@ module.exports = function(crowi) {
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
   };
 
-  /**
-   * Checks if Uploader can respond to the HTTP request.
-   */
-  lib.shouldDelegateToResponse = function() {
-    // Check whether to use internal redirect of nginx or Apache.
-    return configManager.getConfig('crowi', 'fileUpload:local:useInternalRedirect');
-  };
-
   /**
    * Respond to the HTTP request.
    * @param {Response} res

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 35
apps/app/src/server/service/file-uploader/none.js


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio