@xrengine/server-core
Version:
Shared components for XREngine server
373 lines (329 loc) • 11.6 kB
text/typescript
import AWS from 'aws-sdk'
import { ObjectIdentifierList, PresignedPost } from 'aws-sdk/clients/s3'
import fetch from 'node-fetch'
import path from 'path/posix'
import S3BlobStore from 's3-blob-store'
import { FileContentType } from '@xrengine/common/src/interfaces/FileContentType'
import config from '../../appconfig'
import { getCachedURL } from './getCachedURL'
import {
PutObjectParams,
SignedURLResponse,
StorageListObjectInterface,
StorageObjectInterface,
StorageProviderInterface
} from './storageprovider.interface'
/**
* Storage provide class to communicate with AWS S3 API.
*/
export class S3Provider implements StorageProviderInterface {
/**
* Name of S3 bucket.
*/
bucket = config.aws.s3.staticResourceBucket
/**
* Instance of S3 service object. This object has one method for each API operation.
*/
provider: AWS.S3 = new AWS.S3({
accessKeyId: config.aws.keys.accessKeyId,
secretAccessKey: config.aws.keys.secretAccessKey,
endpoint: config.aws.s3.endpoint,
region: config.aws.s3.region,
s3ForcePathStyle: true,
maxRetries: 5
})
/**
* Domain address of S3 cache.
*/
cacheDomain =
config.server.storageProvider === 'aws'
? config.aws.cloudfront.domain
: `${config.aws.cloudfront.domain}/${this.bucket}`
private bucketAssetURL =
config.server.storageProvider === 'aws'
? `https://${this.bucket}.s3.${config.aws.s3.region}.amazonaws.com`
: `https://${config.aws.cloudfront.domain}/${this.bucket}`
private blob: typeof S3BlobStore = new S3BlobStore({
client: this.provider,
bucket: config.aws.s3.staticResourceBucket,
ACL: 'public-read'
})
private cloudfront: AWS.CloudFront = new AWS.CloudFront({
region: config.aws.cloudfront.region,
accessKeyId: config.aws.keys.accessKeyId,
secretAccessKey: config.aws.keys.secretAccessKey
})
/**
* Get the instance of S3 storage provider.
*/
getProvider(): StorageProviderInterface {
return this
}
/**
* Check if an object exists in the S3 storage.
* @param fileName Name of file in the storage.
* @param directoryPath Directory of file in the storage.
*/
async doesExist(fileName: string, directoryPath: string): Promise<boolean> {
// have to use listOBjectsV2 since other object related methods does not check existance of a folder on S3
const result = await this.provider
.listObjectsV2({
Bucket: this.bucket,
Prefix: path.join(directoryPath, fileName),
MaxKeys: 1
})
.promise()
.then((res) => (res.Contents && res.Contents.length > 0) || false)
.catch(() => false)
return result
}
/**
* Check if an object is directory or not.
* @param fileName Name of file in the storage.
* @param directoryPath Directory of file in the storage.
*/
async isDirectory(fileName: string, directoryPath: string): Promise<boolean> {
// last character of the key of directory is '/'
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-folders.htmlhow to
const result = await this.provider
.listObjectsV2({
Bucket: this.bucket,
Prefix: path.join(directoryPath, fileName),
MaxKeys: 1
})
.promise()
.then((res) => res?.Contents?.[0]?.Key?.endsWith('/') || false)
.catch(() => false)
return result
}
/**
* Get the S3 storage object.
* @param key Key of object.
*/
async getObject(key: string): Promise<StorageObjectInterface> {
const data = await this.provider.getObject({ Bucket: this.bucket, Key: key }).promise()
return { Body: data.Body as Buffer, ContentType: data.ContentType! }
}
/**
* Get the object from cache.
* @param key Key of object.
*/
async getCachedObject(key: string): Promise<StorageObjectInterface> {
const data = await fetch(getCachedURL(key, this.cacheDomain))
return { Body: Buffer.from(await data.arrayBuffer()), ContentType: (await data.headers.get('content-type')) || '' }
}
/**
* Get the content type of storage object.
* @param key Key of object.
*/
async getObjectContentType(key: string): Promise<any> {
const data = await this.provider.headObject({ Bucket: this.bucket, Key: key }).promise()
return data.ContentType
}
/**
* Get a list of keys under a path.
* @param prefix Path relative to root in order to list objects.
* @param recursive If true it will list content from sub folders as well. Default is true.
* @param continuationToken It indicates that the list is being continued with a token. Used for certain providers like S3.
* @returns {Promise<StorageListObjectInterface>}
*/
async listObjects(prefix: string, recursive = true, continuationToken?: string): Promise<StorageListObjectInterface> {
const data = await this.provider
.listObjectsV2({
Bucket: this.bucket,
ContinuationToken: continuationToken,
Prefix: prefix,
Delimiter: recursive ? undefined : '/'
})
.promise()
if (!data.Contents) data.Contents = []
if (!data.CommonPrefixes) data.CommonPrefixes = []
if (data.IsTruncated) {
const _data = await this.listObjects(prefix, recursive, data.NextContinuationToken)
data.Contents = data.Contents.concat(_data.Contents)
if (_data.CommonPrefixes) data.CommonPrefixes = data.CommonPrefixes.concat(_data.CommonPrefixes)
}
return data as StorageListObjectInterface
}
/**
* Adds an object into the S3 storage.
* @param object Storage object to be added.
* @param params Parameters of the add request.
*/
async putObject(data: StorageObjectInterface, params: PutObjectParams = {}): Promise<any> {
if (!data.Key) return
// key should not contain '/' at the begining
let key = data.Key[0] === '/' ? data.Key.substring(1) : data.Key
const args = params.isDirectory
? {
ACL: 'public-read',
Body: Buffer.alloc(0),
Bucket: this.bucket,
ContentType: 'application/x-empty',
Key: key + '/'
}
: {
ACL: 'public-read',
Body: data.Body,
Bucket: this.bucket,
ContentType: data.ContentType,
Key: key
}
if (data.ContentEncoding) (args as StorageObjectInterface).ContentEncoding = data.ContentEncoding
if (data.Metadata) (args as StorageObjectInterface).Metadata = data.Metadata
return this.provider.putObject(args).promise()
}
/**
* Invalidate items in the S3 storage.
* @param invalidationItems List of keys.
*/
async createInvalidation(invalidationItems: any[]) {
// for non-standard s3 setups, we don't use cloudfront
if (config.server.storageProvider !== 'aws') return
return this.cloudfront
.createInvalidation({
DistributionId: config.aws.cloudfront.distributionId,
InvalidationBatch: {
CallerReference: Date.now().toString(),
Paths: {
Quantity: invalidationItems.length,
Items: invalidationItems.map((item) => (item[0] !== '/' ? `/${item}` : item))
}
}
})
.promise()
}
/**
* Get the BlobStore object for S3 storage.
*/
getStorage(): typeof S3BlobStore {
this.blob
}
/**
* Get the form fields and target URL for direct POST uploading.
* @param key Key of object.
* @param expiresAfter The number of seconds for which signed policy should be valid. Defaults to 3600 (one hour).
* @param conditions An array of conditions that must be met for the form upload to be accepted by S3..
*/
async getSignedUrl(key: string, expiresAfter: number, conditions): Promise<SignedURLResponse> {
const result = await new Promise<PresignedPost>((resolve) => {
this.provider.createPresignedPost(
{
Bucket: this.bucket,
Fields: {
Key: key
},
Expires: expiresAfter,
Conditions: conditions
},
(err, data: PresignedPost) => {
resolve(data)
}
)
})
await this.createInvalidation([key])
return {
fields: result.fields,
cacheDomain: this.cacheDomain,
url: result.url,
local: false
}
}
/**
* Delete resources in the S3 storage.
* @param keys List of keys.
*/
async deleteResources(keys: string[]) {
// Create batches of 1000 since S3 supports deletion of 1000 object max per request
const batches = [] as ObjectIdentifierList[]
let index = 0
for (let i = 0; i < keys.length; i++) {
index = Math.floor(i / 1000)
if (!batches[index]) batches[index] = []
batches[index].push({ Key: keys[i] })
}
const data = await Promise.all(
batches.map((batch) =>
this.provider
.deleteObjects({
Bucket: this.bucket,
Delete: { Objects: batch }
})
.promise()
)
)
return data
}
/**
* List all the files/folders in the directory.
* @param folderName Name of folder in the storage.
* @param recursive If true it will list content from sub folders as well.
*/
async listFolderContent(folderName: string, recursive = false): Promise<FileContentType[]> {
const folderContent = await this.listObjects(folderName, recursive)
const promises: Promise<FileContentType>[] = []
// Folders
for (let i = 0; i < folderContent.CommonPrefixes!.length; i++) {
promises.push(
new Promise(async (resolve) => {
const key = folderContent.CommonPrefixes![i].Prefix.slice(0, -1)
const cont: FileContentType = {
key,
url: `${this.bucketAssetURL}/${key}`,
name: key.split('/').pop()!,
type: 'folder'
}
resolve(cont)
})
)
}
// Files
for (let i = 0; i < folderContent.Contents.length; i++) {
const key = folderContent.Contents[i].Key
const regexx = /(?:.*)\/(?<name>.*)\.(?<extension>.*)/g
const query = regexx.exec(key)
if (query) {
promises.push(
new Promise(async (resolve) => {
const cont: FileContentType = {
key,
url: `${this.bucketAssetURL}/${key}`,
name: query!.groups!.name,
type: query!.groups!.extension
}
resolve(cont)
})
)
}
}
return await Promise.all(promises)
}
/**
* Move or copy object from one place to another in the S3 storage.
* @param oldName Name of the old object.
* @param newName Name of the new object.
* @param oldPath Path of the old object.
* @param newPath Path of the new object.
* @param isCopy If true it will create a copy of object.
*/
async moveObject(oldName: string, newName: string, oldPath: string, newPath: string, isCopy = false) {
const oldFilePath = path.join(oldPath, oldName)
const newFilePath = path.join(newPath, newName)
const listResponse = await this.listObjects(oldFilePath, true)
const result = await Promise.all([
...listResponse.Contents.map(async (file) =>
this.provider
.copyObject({
ACL: 'public-read',
Bucket: this.bucket,
CopySource: `/${this.bucket}/${file.Key}`,
Key: path.join(newFilePath, file.Key.replace(oldFilePath, ''))
})
.promise()
)
])
if (!isCopy) await this.deleteResources(listResponse.Contents.map((file) => file.Key))
return result
}
}
export default S3Provider