2022-09-08 01:54:12 -05:00
|
|
|
import { createReadStream, createWriteStream, ensureDir, ReadStream } from 'fs-extra'
|
2021-08-17 01:26:20 -05:00
|
|
|
import { dirname } from 'path'
|
|
|
|
import { Readable } from 'stream'
|
|
|
|
import {
|
2022-10-19 03:43:53 -05:00
|
|
|
_Object,
|
2022-09-08 01:54:12 -05:00
|
|
|
CompleteMultipartUploadCommandOutput,
|
2021-08-17 01:26:20 -05:00
|
|
|
DeleteObjectCommand,
|
|
|
|
GetObjectCommand,
|
|
|
|
ListObjectsV2Command,
|
2022-10-19 03:43:53 -05:00
|
|
|
PutObjectAclCommand,
|
|
|
|
PutObjectCommandInput,
|
|
|
|
S3Client
|
2021-08-17 01:26:20 -05:00
|
|
|
} from '@aws-sdk/client-s3'
|
2022-04-19 08:22:18 -05:00
|
|
|
import { Upload } from '@aws-sdk/lib-storage'
|
2021-08-17 01:26:20 -05:00
|
|
|
import { pipelinePromise } from '@server/helpers/core-utils'
|
|
|
|
import { isArray } from '@server/helpers/custom-validators/misc'
|
|
|
|
import { logger } from '@server/helpers/logger'
|
|
|
|
import { CONFIG } from '@server/initializers/config'
|
2022-10-19 03:43:53 -05:00
|
|
|
import { getInternalUrl } from '../urls'
|
2021-08-17 01:26:20 -05:00
|
|
|
import { getClient } from './client'
|
|
|
|
import { lTags } from './logger'
|
|
|
|
|
|
|
|
type BucketInfo = {
|
|
|
|
BUCKET_NAME: string
|
|
|
|
PREFIX?: string
|
|
|
|
}
|
|
|
|
|
2022-10-04 03:03:17 -05:00
|
|
|
async function listKeysOfPrefix (prefix: string, bucketInfo: BucketInfo) {
|
|
|
|
const s3Client = getClient()
|
|
|
|
|
|
|
|
const commandPrefix = bucketInfo.PREFIX + prefix
|
|
|
|
const listCommand = new ListObjectsV2Command({
|
|
|
|
Bucket: bucketInfo.BUCKET_NAME,
|
|
|
|
Prefix: commandPrefix
|
|
|
|
})
|
|
|
|
|
|
|
|
const listedObjects = await s3Client.send(listCommand)
|
|
|
|
|
|
|
|
if (isArray(listedObjects.Contents) !== true) return []
|
|
|
|
|
|
|
|
return listedObjects.Contents.map(c => c.Key)
|
|
|
|
}
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
2021-08-17 01:26:20 -05:00
|
|
|
async function storeObject (options: {
|
|
|
|
inputPath: string
|
|
|
|
objectStorageKey: string
|
|
|
|
bucketInfo: BucketInfo
|
2022-10-19 03:43:53 -05:00
|
|
|
isPrivate: boolean
|
2021-08-17 01:26:20 -05:00
|
|
|
}): Promise<string> {
|
2022-10-19 03:43:53 -05:00
|
|
|
const { inputPath, objectStorageKey, bucketInfo, isPrivate } = options
|
2021-08-17 01:26:20 -05:00
|
|
|
|
|
|
|
logger.debug('Uploading file %s to %s%s in bucket %s', inputPath, bucketInfo.PREFIX, objectStorageKey, bucketInfo.BUCKET_NAME, lTags())
|
|
|
|
|
2022-04-19 08:22:18 -05:00
|
|
|
const fileStream = createReadStream(inputPath)
|
2021-08-17 01:26:20 -05:00
|
|
|
|
2022-10-19 03:43:53 -05:00
|
|
|
return uploadToStorage({ objectStorageKey, content: fileStream, bucketInfo, isPrivate })
|
2021-08-17 01:26:20 -05:00
|
|
|
}
|
|
|
|
|
2022-10-04 03:03:17 -05:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
2022-10-19 03:43:53 -05:00
|
|
|
function updateObjectACL (options: {
|
|
|
|
objectStorageKey: string
|
|
|
|
bucketInfo: BucketInfo
|
|
|
|
isPrivate: boolean
|
|
|
|
}) {
|
|
|
|
const { objectStorageKey, bucketInfo, isPrivate } = options
|
|
|
|
|
|
|
|
const key = buildKey(objectStorageKey, bucketInfo)
|
|
|
|
|
|
|
|
logger.debug('Updating ACL file %s in bucket %s', key, bucketInfo.BUCKET_NAME, lTags())
|
|
|
|
|
|
|
|
const command = new PutObjectAclCommand({
|
2021-08-17 01:26:20 -05:00
|
|
|
Bucket: bucketInfo.BUCKET_NAME,
|
2022-10-19 03:43:53 -05:00
|
|
|
Key: key,
|
|
|
|
ACL: getACL(isPrivate)
|
2021-08-17 01:26:20 -05:00
|
|
|
})
|
|
|
|
|
|
|
|
return getClient().send(command)
|
|
|
|
}
|
|
|
|
|
2022-10-19 03:43:53 -05:00
|
|
|
function updatePrefixACL (options: {
|
|
|
|
prefix: string
|
|
|
|
bucketInfo: BucketInfo
|
|
|
|
isPrivate: boolean
|
|
|
|
}) {
|
|
|
|
const { prefix, bucketInfo, isPrivate } = options
|
|
|
|
|
|
|
|
logger.debug('Updating ACL of files in prefix %s in bucket %s', prefix, bucketInfo.BUCKET_NAME, lTags())
|
|
|
|
|
|
|
|
return applyOnPrefix({
|
|
|
|
prefix,
|
|
|
|
bucketInfo,
|
|
|
|
commandBuilder: obj => {
|
|
|
|
return new PutObjectAclCommand({
|
|
|
|
Bucket: bucketInfo.BUCKET_NAME,
|
|
|
|
Key: obj.Key,
|
|
|
|
ACL: getACL(isPrivate)
|
|
|
|
})
|
|
|
|
}
|
2021-08-17 01:26:20 -05:00
|
|
|
})
|
2022-10-19 03:43:53 -05:00
|
|
|
}
|
2021-08-17 01:26:20 -05:00
|
|
|
|
2022-10-19 03:43:53 -05:00
|
|
|
// ---------------------------------------------------------------------------
|
2021-08-17 01:26:20 -05:00
|
|
|
|
2022-10-19 03:43:53 -05:00
|
|
|
function removeObject (objectStorageKey: string, bucketInfo: BucketInfo) {
|
|
|
|
const key = buildKey(objectStorageKey, bucketInfo)
|
2021-08-17 01:26:20 -05:00
|
|
|
|
2022-10-19 03:43:53 -05:00
|
|
|
logger.debug('Removing file %s in bucket %s', key, bucketInfo.BUCKET_NAME, lTags())
|
2021-08-17 01:26:20 -05:00
|
|
|
|
2022-10-19 03:43:53 -05:00
|
|
|
const command = new DeleteObjectCommand({
|
|
|
|
Bucket: bucketInfo.BUCKET_NAME,
|
|
|
|
Key: key
|
|
|
|
})
|
2021-08-17 01:26:20 -05:00
|
|
|
|
2022-10-19 03:43:53 -05:00
|
|
|
return getClient().send(command)
|
|
|
|
}
|
2021-08-17 01:26:20 -05:00
|
|
|
|
2022-10-19 03:43:53 -05:00
|
|
|
function removePrefix (prefix: string, bucketInfo: BucketInfo) {
|
2021-08-17 01:26:20 -05:00
|
|
|
// FIXME: use bulk delete when s3ninja will support this operation
|
|
|
|
|
2022-10-19 03:43:53 -05:00
|
|
|
logger.debug('Removing prefix %s in bucket %s', prefix, bucketInfo.BUCKET_NAME, lTags())
|
|
|
|
|
|
|
|
return applyOnPrefix({
|
|
|
|
prefix,
|
|
|
|
bucketInfo,
|
|
|
|
commandBuilder: obj => {
|
|
|
|
return new DeleteObjectCommand({
|
|
|
|
Bucket: bucketInfo.BUCKET_NAME,
|
|
|
|
Key: obj.Key
|
|
|
|
})
|
|
|
|
}
|
|
|
|
})
|
2021-08-17 01:26:20 -05:00
|
|
|
}
|
|
|
|
|
2022-10-04 03:03:17 -05:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
2021-08-17 01:26:20 -05:00
|
|
|
async function makeAvailable (options: {
|
|
|
|
key: string
|
|
|
|
destination: string
|
|
|
|
bucketInfo: BucketInfo
|
|
|
|
}) {
|
|
|
|
const { key, destination, bucketInfo } = options
|
|
|
|
|
|
|
|
await ensureDir(dirname(options.destination))
|
|
|
|
|
|
|
|
const command = new GetObjectCommand({
|
|
|
|
Bucket: bucketInfo.BUCKET_NAME,
|
|
|
|
Key: buildKey(key, bucketInfo)
|
|
|
|
})
|
|
|
|
const response = await getClient().send(command)
|
|
|
|
|
|
|
|
const file = createWriteStream(destination)
|
|
|
|
await pipelinePromise(response.Body as Readable, file)
|
|
|
|
|
|
|
|
file.close()
|
|
|
|
}
|
|
|
|
|
|
|
|
function buildKey (key: string, bucketInfo: BucketInfo) {
|
|
|
|
return bucketInfo.PREFIX + key
|
|
|
|
}
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
2022-10-19 03:43:53 -05:00
|
|
|
async function createObjectReadStream (options: {
|
|
|
|
key: string
|
|
|
|
bucketInfo: BucketInfo
|
|
|
|
rangeHeader: string
|
|
|
|
}) {
|
|
|
|
const { key, bucketInfo, rangeHeader } = options
|
|
|
|
|
|
|
|
const command = new GetObjectCommand({
|
|
|
|
Bucket: bucketInfo.BUCKET_NAME,
|
|
|
|
Key: buildKey(key, bucketInfo),
|
|
|
|
Range: rangeHeader
|
|
|
|
})
|
|
|
|
|
|
|
|
const response = await getClient().send(command)
|
|
|
|
|
|
|
|
return response.Body as Readable
|
|
|
|
}
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
2021-08-17 01:26:20 -05:00
|
|
|
export {
|
|
|
|
BucketInfo,
|
|
|
|
buildKey,
|
2022-10-19 03:43:53 -05:00
|
|
|
|
2021-08-17 01:26:20 -05:00
|
|
|
storeObject,
|
2022-10-19 03:43:53 -05:00
|
|
|
|
2021-08-17 01:26:20 -05:00
|
|
|
removeObject,
|
|
|
|
removePrefix,
|
2022-10-19 03:43:53 -05:00
|
|
|
|
2022-10-04 03:03:17 -05:00
|
|
|
makeAvailable,
|
2022-10-19 03:43:53 -05:00
|
|
|
|
|
|
|
updateObjectACL,
|
|
|
|
updatePrefixACL,
|
|
|
|
|
|
|
|
listKeysOfPrefix,
|
|
|
|
createObjectReadStream
|
2021-08-17 01:26:20 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
2022-09-08 01:54:12 -05:00
|
|
|
async function uploadToStorage (options: {
|
2022-04-19 08:22:18 -05:00
|
|
|
content: ReadStream
|
2021-08-17 01:26:20 -05:00
|
|
|
objectStorageKey: string
|
|
|
|
bucketInfo: BucketInfo
|
2022-10-19 03:43:53 -05:00
|
|
|
isPrivate: boolean
|
2021-08-17 01:26:20 -05:00
|
|
|
}) {
|
2022-10-19 03:43:53 -05:00
|
|
|
const { content, objectStorageKey, bucketInfo, isPrivate } = options
|
2021-08-17 01:26:20 -05:00
|
|
|
|
2022-04-19 08:22:18 -05:00
|
|
|
const input: PutObjectCommandInput = {
|
|
|
|
Body: content,
|
2021-08-17 01:26:20 -05:00
|
|
|
Bucket: bucketInfo.BUCKET_NAME,
|
2022-10-19 03:43:53 -05:00
|
|
|
Key: buildKey(objectStorageKey, bucketInfo),
|
|
|
|
ACL: getACL(isPrivate)
|
2022-03-15 10:57:12 -05:00
|
|
|
}
|
|
|
|
|
2022-04-19 08:22:18 -05:00
|
|
|
const parallelUploads3 = new Upload({
|
|
|
|
client: getClient(),
|
|
|
|
queueSize: 4,
|
|
|
|
partSize: CONFIG.OBJECT_STORAGE.MAX_UPLOAD_PART,
|
2022-09-08 01:54:12 -05:00
|
|
|
|
|
|
|
// `leavePartsOnError` must be set to `true` to avoid silently dropping failed parts
|
|
|
|
// More detailed explanation:
|
|
|
|
// https://github.com/aws/aws-sdk-js-v3/blob/v3.164.0/lib/lib-storage/src/Upload.ts#L274
|
|
|
|
// https://github.com/aws/aws-sdk-js-v3/issues/2311#issuecomment-939413928
|
|
|
|
leavePartsOnError: true,
|
2022-04-19 08:22:18 -05:00
|
|
|
params: input
|
2021-08-17 01:26:20 -05:00
|
|
|
})
|
2022-04-19 08:22:18 -05:00
|
|
|
|
2022-09-08 01:54:12 -05:00
|
|
|
const response = (await parallelUploads3.done()) as CompleteMultipartUploadCommandOutput
|
|
|
|
// Check is needed even if the HTTP status code is 200 OK
|
|
|
|
// For more information, see https://docs.aws.amazon.com/AmazonS3/latest/API/API_CompleteMultipartUpload.html
|
|
|
|
if (!response.Bucket) {
|
|
|
|
const message = `Error uploading ${objectStorageKey} to bucket ${bucketInfo.BUCKET_NAME}`
|
|
|
|
logger.error(message, { response, ...lTags() })
|
|
|
|
throw new Error(message)
|
|
|
|
}
|
2021-08-17 01:26:20 -05:00
|
|
|
|
|
|
|
logger.debug(
|
2022-04-19 08:22:18 -05:00
|
|
|
'Completed %s%s in bucket %s',
|
|
|
|
bucketInfo.PREFIX, objectStorageKey, bucketInfo.BUCKET_NAME, lTags()
|
2021-08-17 01:26:20 -05:00
|
|
|
)
|
|
|
|
|
2022-10-19 03:43:53 -05:00
|
|
|
return getInternalUrl(bucketInfo, objectStorageKey)
|
|
|
|
}
|
|
|
|
|
|
|
|
async function applyOnPrefix (options: {
|
|
|
|
prefix: string
|
|
|
|
bucketInfo: BucketInfo
|
|
|
|
commandBuilder: (obj: _Object) => Parameters<S3Client['send']>[0]
|
|
|
|
|
|
|
|
continuationToken?: string
|
|
|
|
}) {
|
|
|
|
const { prefix, bucketInfo, commandBuilder, continuationToken } = options
|
|
|
|
|
|
|
|
const s3Client = getClient()
|
|
|
|
|
|
|
|
const commandPrefix = bucketInfo.PREFIX + prefix
|
|
|
|
const listCommand = new ListObjectsV2Command({
|
|
|
|
Bucket: bucketInfo.BUCKET_NAME,
|
|
|
|
Prefix: commandPrefix,
|
|
|
|
ContinuationToken: continuationToken
|
|
|
|
})
|
|
|
|
|
|
|
|
const listedObjects = await s3Client.send(listCommand)
|
|
|
|
|
|
|
|
if (isArray(listedObjects.Contents) !== true) {
|
|
|
|
const message = `Cannot apply function on ${commandPrefix} prefix in bucket ${bucketInfo.BUCKET_NAME}: no files listed.`
|
|
|
|
|
|
|
|
logger.error(message, { response: listedObjects, ...lTags() })
|
|
|
|
throw new Error(message)
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const object of listedObjects.Contents) {
|
|
|
|
const command = commandBuilder(object)
|
|
|
|
|
|
|
|
await s3Client.send(command)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Repeat if not all objects could be listed at once (limit of 1000?)
|
|
|
|
if (listedObjects.IsTruncated) {
|
|
|
|
await applyOnPrefix({ ...options, continuationToken: listedObjects.ContinuationToken })
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function getACL (isPrivate: boolean) {
|
|
|
|
return isPrivate
|
|
|
|
? CONFIG.OBJECT_STORAGE.UPLOAD_ACL.PRIVATE
|
|
|
|
: CONFIG.OBJECT_STORAGE.UPLOAD_ACL.PUBLIC
|
2021-08-17 01:26:20 -05:00
|
|
|
}
|