Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add a function to check existing file on s3 bucket #496

Merged
merged 12 commits into from
Sep 12, 2023
13 changes: 12 additions & 1 deletion packages/s3/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
43 changes: 38 additions & 5 deletions packages/s3/src/model/files/service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
13 changes: 12 additions & 1 deletion packages/s3/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,6 +23,7 @@ interface PresignedUrlOptions extends BaseOption {

interface FilePayloadOptions extends BaseOption {
bucketChoice?: BucketChoice;
filenameResolutionStrategy?: FilenameResolutionStrategy;
path?: string;
}

Expand Down
40 changes: 39 additions & 1 deletion packages/s3/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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(".");

Expand Down Expand Up @@ -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,
};
75 changes: 72 additions & 3 deletions packages/s3/src/utils/s3Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string | undefined>} A Promise that resolves with the generated presigned URL or undefined if an error occurs.
*/
public async generatePresignedUrl(
filePath: string,
originalFileName: string,
Expand All @@ -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,
Expand All @@ -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<PutObjectCommandOutput>} A Promise that resolves with information about the uploaded object.
*/
public async upload(
fileStream: Buffer,
key: string,
mimetype: string
): Promise<PutObjectCommandOutput> {
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<boolean> - True if the file exists; otherwise, false.
*/
public async isFileExists(key: string): Promise<boolean> {
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<ListObjectsCommandOutput>} A Promise that resolves to the result of the list operation.
*/
public async getObjects(baseName: string): Promise<ListObjectsCommandOutput> {
return await this._storageClient.send(
new ListObjectsCommand({
Bucket: this.bucket,
Prefix: baseName,
})
);
}

protected init(): S3Client {
Expand Down