Explorar o código

Merge pull request #8245 from weseek/imprv/apply-content-headers-for-attachment-response

imprv: Apply content headers for attachment response
Yuki Takei %!s(int64=2) %!d(string=hai) anos
pai
achega
e2fcec2c4f

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

@@ -190,7 +190,6 @@ class AwsFileUploader extends AbstractFileUploader {
       throw new Error('AWS is not configured.');
     }
 
-
     const s3 = S3Factory();
     const awsConfig = getAwsConfig();
     const filePath = getFilePathOnStorage(attachment);
@@ -198,13 +197,13 @@ class AwsFileUploader extends AbstractFileUploader {
 
     // issue signed url (default: expires 120 seconds)
     // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
-    // const isDownload = opts?.download ?? false;
-    // const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    const isDownload = opts?.download ?? false;
+    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
     const params: GetObjectCommandInput = {
       Bucket: awsConfig.bucket,
       Key: filePath,
-      // ResponseContentType: contentHeaders.contentType?.value.toString(),
-      // ResponseContentDisposition: contentHeaders.contentDisposition?.value.toString(),
+      ResponseContentType: contentHeaders.contentType?.value.toString(),
+      ResponseContentDisposition: contentHeaders.contentDisposition?.value.toString(),
     };
     const signedUrl = await getSignedUrl(s3, new GetObjectCommand(params), {
       expiresIn: lifetimeSecForTemporaryUrl,
@@ -288,30 +287,30 @@ module.exports = (crowi) => {
     const awsConfig = getAwsConfig();
 
     const filePath = getFilePathOnStorage(attachment);
-    const params = {
+    const contentHeaders = new ContentHeaders(attachment);
+
+    return s3.send(new PutObjectCommand({
       Bucket: awsConfig.bucket,
-      ContentType: attachment.fileFormat,
       Key: filePath,
       Body: fileStream,
       ACL: ObjectCannedACL.public_read,
-    };
-
-    return s3.send(new PutObjectCommand(params));
+      // put type and the file name for reference information when uploading
+      ContentType: contentHeaders.contentType?.value.toString(),
+      ContentDisposition: contentHeaders.contentDisposition?.value.toString(),
+    }));
   };
 
   lib.saveFile = async function({ filePath, contentType, data }) {
     const s3 = S3Factory();
     const awsConfig = getAwsConfig();
 
-    const params = {
+    return s3.send(new PutObjectCommand({
       Bucket: awsConfig.bucket,
       ContentType: contentType,
       Key: filePath,
       Body: data,
       ACL: ObjectCannedACL.public_read,
-    };
-
-    return s3.send(new PutObjectCommand(params));
+    }));
   };
 
   (lib as any).checkLimit = async function(uploadFileSize) {

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

@@ -1,19 +1,16 @@
-import path from 'path';
-
 import { ClientSecretCredential, TokenCredential } from '@azure/identity';
 import {
+  generateBlobSASQueryParameters,
   BlobServiceClient,
   BlobClient,
   BlockBlobClient,
   BlobDeleteOptions,
   ContainerClient,
-  generateBlobSASQueryParameters,
   ContainerSASPermissions,
   SASProtocol,
   type BlobDeleteIfExistsResponse,
   type BlockBlobUploadResponse,
   type BlockBlobParallelUploadOptions,
-  type BlockBlobUploadStreamOptions,
 } from '@azure/storage-blob';
 
 import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
@@ -62,33 +59,6 @@ async function getContainerClient(): Promise<ContainerClient> {
   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);
@@ -165,27 +135,51 @@ class AzureFileUploader extends AbstractFileUploader {
 
   /**
    * @inheritDoc
+   * @see https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blob-create-user-delegation-sas-javascript
    */
   override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
     if (!this.getIsUploadable()) {
       throw new Error('Azure Blob is not configured.');
     }
 
-    const containerClient = await getContainerClient();
-    const filePath = getFilePathOnStorage(attachment);
-    const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
     const lifetimeSecForTemporaryUrl = configManager.getConfig('crowi', 'azure:lifetimeSecForTemporaryUrl');
 
-    const sasToken = await getSasToken(lifetimeSecForTemporaryUrl);
-    const signedUrl = `${blockBlobClient.url}?${sasToken}`;
-
-    // TODO: re-impl using generateSasUrl
-    // const isDownload = opts?.download ?? false;
-    // const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
-    // const signedUrl = blockBlobClient.generateSasUrl({
-    //   contentType: contentHeaders.contentType?.value.toString(),
-    //   contentDisposition: contentHeaders.contentDisposition?.value.toString(),
-    // });
+    const url = await (async() => {
+      const containerClient = await getContainerClient();
+      const filePath = getFilePathOnStorage(attachment);
+      const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
+      return blockBlobClient.url;
+    })();
+
+    const sasToken = await (async() => {
+      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 + lifetimeSecForTemporaryUrl * 1000);
+      const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);
+
+      const isDownload = opts?.download ?? false;
+      const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+
+      // 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,
+        contentType: contentHeaders.contentType?.value.toString(),
+        contentDisposition: contentHeaders.contentDisposition?.value.toString(),
+      };
+
+      return generateBlobSASQueryParameters(sasOptions, userDelegationKey, accountName).toString();
+    })();
+
+    const signedUrl = `${url}?${sasToken}`;
 
     return {
       url: signedUrl,
@@ -233,15 +227,15 @@ module.exports = (crowi) => {
     const filePath = getFilePathOnStorage(attachment);
     const containerClient = await getContainerClient();
     const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
-    const DEFAULT_BLOCK_BUFFER_SIZE_BYTES: number = 8 * 1024 * 1024; // 8MB
-    const DEFAULT_MAX_CONCURRENCY = 5;
-    const options: BlockBlobUploadStreamOptions = {
+    const contentHeaders = new ContentHeaders(attachment);
+
+    return blockBlobClient.uploadStream(readStream, undefined, undefined, {
       blobHTTPHeaders: {
-        blobContentType: attachment.fileFormat,
-        blobContentDisposition: `attachment;filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
+        // put type and the file name for reference information when uploading
+        blobContentType: contentHeaders.contentType?.value.toString(),
+        blobContentDisposition: contentHeaders.contentDisposition?.value.toString(),
       },
-    };
-    return blockBlobClient.uploadStream(readStream, DEFAULT_BLOCK_BUFFER_SIZE_BYTES, DEFAULT_MAX_CONCURRENCY, options);
+    });
   };
 
   lib.saveFile = async function({ filePath, contentType, data }) {
@@ -250,7 +244,6 @@ module.exports = (crowi) => {
     const options: BlockBlobParallelUploadOptions = {
       blobHTTPHeaders: {
         blobContentType: contentType,
-        blobContentDisposition: `attachment;filename*=UTF-8''${encodeURIComponent(path.basename(filePath))}`,
       },
     };
     const blockBlobUploadResponse: BlockBlobUploadResponse = await blockBlobClient.upload(data, data.length, options);

+ 10 - 8
apps/app/src/server/service/file-uploader/gcs.ts

@@ -145,13 +145,13 @@ class GcsFileUploader extends AbstractFileUploader {
 
     // issue signed url (default: expires 120 seconds)
     // https://cloud.google.com/storage/docs/access-control/signed-urls
-    // const isDownload = opts?.download ?? false;
-    // const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    const isDownload = opts?.download ?? false;
+    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
     const [signedUrl] = await file.getSignedUrl({
       action: 'read',
       expires: Date.now() + lifetimeSecForTemporaryUrl * 1000,
-      // responseType: contentHeaders.contentType?.value.toString(),
-      // responseDisposition: contentHeaders.contentDisposition?.value.toString(),
+      responseType: contentHeaders.contentType?.value.toString(),
+      responseDisposition: contentHeaders.contentDisposition?.value.toString(),
     });
 
     return {
@@ -211,11 +211,13 @@ module.exports = function(crowi: Crowi) {
     const gcs = getGcsInstance();
     const myBucket = gcs.bucket(getGcsBucket());
     const filePath = getFilePathOnStorage(attachment);
-    const options = {
-      destination: filePath,
-    };
+    const contentHeaders = new ContentHeaders(attachment);
 
-    return myBucket.upload(fileStream.path, options);
+    return myBucket.upload(fileStream.path, {
+      destination: filePath,
+      // put type and the file name for reference information when uploading
+      contentType: contentHeaders.contentType?.value.toString(),
+    });
   };
 
   lib.saveFile = async function({ filePath, contentType, data }) {

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

@@ -11,6 +11,7 @@ import loggerFactory from '~/utils/logger';
 import { configManager } from '../config-manager';
 
 import { AbstractFileUploader, type TemporaryUrl, type SaveFileParam } from './file-uploader';
+import { ContentHeaders } from './utils';
 
 const logger = loggerFactory('growi:service:fileUploaderGridfs');
 
@@ -152,10 +153,13 @@ module.exports = function(crowi) {
   (lib as any).uploadAttachment = async function(fileStream, attachment) {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
+    const contentHeaders = new ContentHeaders(attachment);
+
     return AttachmentFile.promisifiedWrite(
       {
+        // put type and the file name for reference information when uploading
         filename: attachment.fileName,
-        contentType: attachment.fileFormat,
+        contentType: contentHeaders.contentType?.value.toString(),
       },
       fileStream,
     );

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

@@ -218,10 +218,10 @@ module.exports = function(crowi) {
     const internalPathRoot = configManager.getConfig('crowi', 'fileUpload:local:internalRedirectPath');
     const internalPath = urljoin(internalPathRoot, relativePath);
 
-    // const isDownload = opts?.download ?? false;
-    // const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    const isDownload = opts?.download ?? false;
+    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
     applyHeaders(res, [
-      // ...contentHeaders.toExpressHttpHeaders(),
+      ...contentHeaders.toExpressHttpHeaders(),
       { field: 'X-Accel-Redirect', value: internalPath },
       { field: 'X-Sendfile', value: storagePath },
     ]);