diff --git a/packages/s3/src/constants.ts b/packages/s3/src/constants.ts index c2b351aee..3dbd17269 100644 --- a/packages/s3/src/constants.ts +++ b/packages/s3/src/constants.ts @@ -2,4 +2,15 @@ const TABLE_FILES = "files"; const BUCKET_FROM_OPTIONS = "optionsBucket"; const BUCKET_FROM_FILE_FIELDS = "fileFieldsBucket"; -export { BUCKET_FROM_FILE_FIELDS, BUCKET_FROM_OPTIONS, TABLE_FILES }; +const OVERRIDE = "override"; +const ADD_SUFFIX = "add-suffix"; +const ERROR = "error"; + +export { + ADD_SUFFIX, + BUCKET_FROM_FILE_FIELDS, + BUCKET_FROM_OPTIONS, + ERROR, + OVERRIDE, + TABLE_FILES, +}; diff --git a/packages/s3/src/model/files/service.ts b/packages/s3/src/model/files/service.ts index b1799a7fb..9349f1e93 100644 --- a/packages/s3/src/model/files/service.ts +++ b/packages/s3/src/model/files/service.ts @@ -1,9 +1,14 @@ import { BaseService } from "@dzangolab/fastify-slonik"; import { v4 as uuidv4 } from "uuid"; -import { TABLE_FILES } from "../../constants"; +import { ADD_SUFFIX, ERROR, OVERRIDE, TABLE_FILES } from "../../constants"; import { PresignedUrlOptions, FilePayload } from "../../types/"; -import { getPreferredBucket, getFileExtension } from "../../utils"; +import { + getPreferredBucket, + getFileExtension, + getFilenameWithSuffix, + getBaseName, +} from "../../utils"; import S3Client from "../../utils/s3Client"; import type { Service } from "@dzangolab/fastify-slonik"; @@ -110,7 +115,12 @@ class FileService< upload = async (data: FilePayload) => { const { fileContent, fileFields } = data.file; const { filename, mimetype, data: fileData } = fileContent; - const { path = "", bucket = "", bucketChoice } = data.options || {}; + const { + path = "", + bucket = "", + bucketChoice, + filenameResolutionStrategy = OVERRIDE, + } = data.options || {}; const fileExtension = getFileExtension(filename); this.fileExtension = fileExtension; @@ -119,8 +129,31 @@ class FileService< this.s3Client.bucket = getPreferredBucket(bucket, fileFields?.bucket, bucketChoice) || ""; - const key = this.key; - + let key = this.key; + + // check file exist + const headObjectResponse = await this.s3Client.isFileExists(key); + + if (headObjectResponse) { + switch (filenameResolutionStrategy) { + case ERROR: { + throw new Error("File already exists in S3."); + } + case ADD_SUFFIX: { + const baseFilename = getBaseName(this.filename); + const listObjects = await this.s3Client.getObjects(baseFilename); + + const filenameWithSuffix = getFilenameWithSuffix( + listObjects, + baseFilename, + this.fileExtension + ); + this.filename = filenameWithSuffix; + key = this.key; + break; + } + } + } const uploadResult = await this.s3Client.upload(fileData, key, mimetype); if (!uploadResult) { diff --git a/packages/s3/src/types/index.ts b/packages/s3/src/types/index.ts index 3bd0d7414..75d050234 100644 --- a/packages/s3/src/types/index.ts +++ b/packages/s3/src/types/index.ts @@ -1,7 +1,17 @@ import { FileCreateInput } from "./file"; -import { BUCKET_FROM_FILE_FIELDS, BUCKET_FROM_OPTIONS } from "../constants"; +import { + ADD_SUFFIX, + BUCKET_FROM_FILE_FIELDS, + BUCKET_FROM_OPTIONS, + ERROR, + OVERRIDE, +} from "../constants"; type BucketChoice = typeof BUCKET_FROM_FILE_FIELDS | typeof BUCKET_FROM_OPTIONS; +type FilenameResolutionStrategy = + | typeof ADD_SUFFIX + | typeof ERROR + | typeof OVERRIDE; interface BaseOption { bucket?: string; @@ -13,6 +23,7 @@ interface PresignedUrlOptions extends BaseOption { interface FilePayloadOptions extends BaseOption { bucketChoice?: BucketChoice; + filenameResolutionStrategy?: FilenameResolutionStrategy; path?: string; } diff --git a/packages/s3/src/utils/index.ts b/packages/s3/src/utils/index.ts index f638b87ff..449235b3d 100644 --- a/packages/s3/src/utils/index.ts +++ b/packages/s3/src/utils/index.ts @@ -1,6 +1,16 @@ +import { ListObjectsOutput } from "@aws-sdk/client-s3"; + import { BUCKET_FROM_FILE_FIELDS, BUCKET_FROM_OPTIONS } from "../constants"; import { BucketChoice } from "../types"; +const getBaseName = (filename: string): string => { + let baseName = filename.replace(/\.[^.]+$/, ""); + + baseName = baseName.replace(/-\d+$/, ""); + + return baseName; +}; + const getFileExtension = (filename: string): string => { const lastDotIndex = filename.lastIndexOf("."); @@ -35,4 +45,32 @@ const getPreferredBucket = ( return fileFieldsBucket || optionsBucket; }; -export { getFileExtension, getPreferredBucket }; +const getFilenameWithSuffix = ( + listObjects: ListObjectsOutput, + baseFilename: string, + fileExtension: string +): string => { + const contents = listObjects.Contents; + const highestSuffix = contents?.reduce((maxNumber, item) => { + const matches = item.Key?.match(/-(\d+)\.\w+$/); + + if (matches) { + const number = Number.parseInt(matches[1]); + + return Math.max(maxNumber, number); + } + + return maxNumber; + }, 0); + + const nextNumber = highestSuffix ? highestSuffix + 1 : 1; + + return `${baseFilename}-${nextNumber}.${fileExtension}`; +}; + +export { + getBaseName, + getFileExtension, + getPreferredBucket, + getFilenameWithSuffix, +}; diff --git a/packages/s3/src/utils/s3Client.ts b/packages/s3/src/utils/s3Client.ts index 990e76096..ae65a27cf 100644 --- a/packages/s3/src/utils/s3Client.ts +++ b/packages/s3/src/utils/s3Client.ts @@ -4,6 +4,10 @@ import { S3Client, GetObjectCommand, PutObjectCommand, + HeadObjectCommand, + PutObjectCommandOutput, + ListObjectsCommand, + ListObjectsCommandOutput, } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; @@ -31,6 +35,14 @@ class s3Client { this._bucket = bucket; } + /** + * Generates a presigned URL for downloading a file from the specified S3 bucket. + * + * @param {string} filePath - The path or key of the file in the bucket. + * @param {string} originalFileName - The name to be used when downloading the file. + * @param {number} signedUrlExpiresInSecond - (Optional) The expiration time of the presigned URL in seconds (default: 3600 seconds). + * @returns {Promise} A Promise that resolves with the generated presigned URL or undefined if an error occurs. + */ public async generatePresignedUrl( filePath: string, originalFileName: string, @@ -47,6 +59,12 @@ class s3Client { }); } + /** + * Retrieves a file from the specified S3 bucket. + * + * @param {string} filePath - The path or key of the file to retrieve from the bucket. + * @returns {Promise<{ ContentType: string, Body: Buffer }>} A Promise that resolves with the retrieved file's content type and content as a Buffer. + */ public async get(filePath: string) { const command = new GetObjectCommand({ Bucket: this.bucket, @@ -65,15 +83,66 @@ class s3Client { }; } - public async upload(fileStream: Buffer, key: string, mimetype: string) { - const command = new PutObjectCommand({ + /** + * Uploads a file to the specified S3 bucket. + * + * @param {Buffer} fileStream - The file content as a Buffer. + * @param {string} key - The key (file name) to use when storing the file in the bucket. + * @param {string} mimetype - The MIME type of the file. + * @returns {Promise} A Promise that resolves with information about the uploaded object. + */ + public async upload( + fileStream: Buffer, + key: string, + mimetype: string + ): Promise { + const putCommand = new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: fileStream, ContentType: mimetype, }); - return await this._storageClient.send(command); + return await this._storageClient.send(putCommand); + } + + /** + * Checks if a file with the given key exists in the S3 bucket. + * @param key - The key (combination of path & file name) to check for in the bucket. + * @returns Promise - True if the file exists; otherwise, false. + */ + public async isFileExists(key: string): Promise { + try { + const headObjectCommand = new HeadObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + + const response = await this._storageClient.send(headObjectCommand); + + return !!response; + } catch (error: any) { + if (error.name === "NotFound") { + return false; + } + + throw error; + } + } + + /** + * Retrieves a list of objects from the S3 bucket with a specified prefix. + * + * @param {string} baseName - The prefix used to filter objects within the S3 bucket. + * @returns {Promise} A Promise that resolves to the result of the list operation. + */ + public async getObjects(baseName: string): Promise { + return await this._storageClient.send( + new ListObjectsCommand({ + Bucket: this.bucket, + Prefix: baseName, + }) + ); } protected init(): S3Client {