import { defineStore } from "pinia"; import type { _Object as S3Object, HeadObjectOutput } from "@aws-sdk/client-s3"; import { AbortMultipartUploadCommand, CopyObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; import { paginateListObjectsV2, DeleteObjectCommand, DeleteObjectsCommand, } from "@aws-sdk/client-s3"; import { environment } from "@/environment"; import type { S3Key } from "@/client/s3proxy"; import { useBucketStore } from "@/stores/buckets"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import dayjs from "dayjs"; import { Upload } from "@aws-sdk/lib-storage"; import type { Progress } from "@aws-sdk/lib-storage"; import type { AbortController } from "@smithy/types"; export const useS3ObjectStore = defineStore({ id: "s3objects", state: () => ({ objectMapping: {}, objectMetaMapping: {}, client: new S3Client({ region: "us-east-1", endpoint: environment.S3_URL, forcePathStyle: true, credentials: { accessKeyId: "", secretAccessKey: "", }, }), }) as { objectMapping: Record<string, S3Object[]>; objectMetaMapping: Record<string, HeadObjectOutput>; client: S3Client; }, getters: { getPresignedUrl(): (bucketName: string, key: string) => Promise<string> { return (bucketName, key) => { const keySplit = key.split("/"); const command = new GetObjectCommand({ Bucket: bucketName, Key: key, ResponseContentDisposition: `attachment; filename=${ keySplit[keySplit.length - 1] }`, }); return getSignedUrl(this.client, command, { expiresIn: 30, }); }; }, getMeta(): (bucketName: string) => [number, number] { // Compute the number of objects and the cumulative size of all objects in a bucket return (bucketName) => { return [ this.objectMapping[bucketName]?.length ?? 0, this.objectMapping[bucketName]?.reduce( (acc, obj) => acc + (obj.Size ?? 0), 0, ) ?? 0, ]; }; }, }, actions: { _pushObject(bucketName: string, newObj: S3Object) { if (this.objectMapping[bucketName] == undefined) { this.fetchS3Objects(bucketName); } else { const objIndex = this.objectMapping[bucketName].findIndex( (obj) => obj.Key === newObj.Key, ); if (objIndex > -1) { this.objectMapping[bucketName][objIndex] = newObj; } else { this.objectMapping[bucketName].push(newObj); } } }, updateS3Client(s3Key: S3Key) { this.client = new S3Client({ region: "us-east-1", endpoint: environment.S3_URL, forcePathStyle: true, credentials: { accessKeyId: s3Key.access_key, secretAccessKey: s3Key.secret_key, }, }); }, async fetchS3Objects( bucketName: string, prefix?: string, onFinally?: () => void, ): Promise<S3Object[]> { if (this.objectMapping[bucketName] != undefined) { onFinally?.(); } const pag = paginateListObjectsV2( { client: this.client }, { Bucket: bucketName, Prefix: prefix }, ); const objs: S3Object[] = []; try { for await (const page of pag) { objs.push(...(page.Contents ?? [])); } this.objectMapping[bucketName] = objs; } finally { onFinally?.(); } return objs; }, fetchS3ObjectMeta( bucketName: string, key: string, onFinally?: () => void, ): Promise<HeadObjectOutput> { const identifier = bucketName + "/" + key; if (this.objectMetaMapping[identifier]) { onFinally?.(); } const command = new HeadObjectCommand({ Bucket: bucketName, Key: key, }); return this.client .send(command) .then((resp) => { this.objectMetaMapping[identifier] = resp; return resp; }) .finally(onFinally); }, deleteObject(bucketName: string, key: string): Promise<void> { const command = new DeleteObjectCommand({ Bucket: bucketName, Key: key, }); return this.client .send(command) .then(() => { const bucketRepository = useBucketStore(); bucketRepository.fetchBucket(bucketName); if (this.objectMapping[bucketName] == undefined) { this.fetchS3Objects(bucketName); } else { this.objectMapping[bucketName] = this.objectMapping[ bucketName ].filter((obj) => obj.Key !== key); } }) .catch((err) => { console.error(err); }); }, deleteObjectsWithPrefix(bucketName: string, prefix: string): Promise<void> { if (this.objectMapping[bucketName] == undefined) { return Promise.resolve(); } const command = new DeleteObjectsCommand({ Bucket: bucketName, Delete: { Objects: this.objectMapping[bucketName] .filter((obj) => obj.Key?.startsWith(prefix)) .map((obj) => { return { Key: obj.Key }; }), }, }); return this.client.send(command).then(() => { const bucketRepository = useBucketStore(); bucketRepository.fetchBucket(bucketName); if (this.objectMapping[bucketName] == undefined) { this.fetchS3Objects(bucketName); } else { this.objectMapping[bucketName] = this.objectMapping[ bucketName ].filter((obj) => !obj.Key?.startsWith(prefix)); } }); }, copyObject( srcBucket: string, srcObject: S3Object, destBucket: string, destKey: string, abortController?: AbortController, ): Promise<S3Object> { if (srcObject.Key == undefined) { return Promise.resolve({}); } const command = new CopyObjectCommand({ Bucket: destBucket, CopySource: encodeURI(`/${srcBucket}/${srcObject.Key}`), Key: destKey, }); return this.client .send(command, { abortSignal: abortController?.signal }) .then(() => { const newObj = { Key: destKey, Size: srcObject.Size, LastModified: dayjs().toDate(), }; this._pushObject(destBucket, newObj); return newObj; }); }, async uploadObjectFile( bucketName: string, key: string, file: File, onProgress?: (progress: Progress) => void, abortController?: AbortController, ): Promise<S3Object> { const parallelUploads3 = new Upload({ client: this.client, params: { Bucket: bucketName, Body: file, ContentType: file.type, Key: key, }, queueSize: 4, // optional concurrency configuration leavePartsOnError: false, // optional manually handle dropped parts abortController: abortController, }); if (onProgress != undefined) { parallelUploads3.on("httpUploadProgress", onProgress); } try { await parallelUploads3.done(); } catch (e) { // if there is an error with the multipart upload, send an abort multipart upload command const uploadObject = JSON.parse(JSON.stringify(parallelUploads3)); if (uploadObject["isMultiPart"] && uploadObject["uploadId"]) { await this.client.send( new AbortMultipartUploadCommand({ // AbortMultipartUploadRequest Bucket: bucketName, // required Key: key, // required UploadId: uploadObject["uploadId"], // required }), ); } throw e; } const newObj = { Key: key, Size: file.size ?? 0, LastModified: dayjs().toDate(), }; this._pushObject(bucketName, newObj); return newObj; }, createFolder(bucketName: string, key: string): Promise<S3Object> { const command = new PutObjectCommand({ Bucket: bucketName, Body: "", ContentType: "text/plain", Key: key, }); return this.client.send(command).then(() => { const newObj = { Key: key, Size: 0, LastModified: dayjs().toDate(), }; this._pushObject(bucketName, newObj); return newObj; }); }, }, });