@strapi/provider-upload-aws-s3
Version:
AWS S3 provider for strapi upload
438 lines (435 loc) • 19.4 kB
JavaScript
import { getOr } from 'lodash/fp';
import { S3Client, DeleteObjectCommand, GetObjectCommand, ObjectCannedACL, HeadObjectCommand, ChecksumAlgorithm, StorageClass, ServerSideEncryption } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Upload } from '@aws-sdk/lib-storage';
import { isUrlFromBucket, extractCredentials } from './utils.mjs';
/**
* Validates that a URL uses HTTP or HTTPS protocol.
* Rejects dangerous protocols like file://, javascript:, data:, etc.
*/ const assertUrlProtocol = (url)=>{
return /^https?:\/\//.test(url);
};
/**
* Sanitizes a path component to prevent path traversal attacks.
* Removes directory traversal sequences and normalizes the path.
*/ const sanitizePathComponent = (component)=>{
if (!component) return '';
return component.replace(/\.\./g, '').replace(/^\/+|\/+$/g, '').replace(/\/+/g, '/');
};
/**
* Maps the provider checksum algorithm to the AWS SDK checksum algorithm.
*/ const mapChecksumAlgorithm = (algorithm)=>{
if (!algorithm) return undefined;
const mapping = {
CRC32: ChecksumAlgorithm.CRC32,
CRC32C: ChecksumAlgorithm.CRC32C,
SHA1: ChecksumAlgorithm.SHA1,
SHA256: ChecksumAlgorithm.SHA256,
CRC64NVME: ChecksumAlgorithm.CRC64NVME
};
return mapping[algorithm];
};
/**
* Maps the provider storage class to the AWS SDK storage class.
*/ const mapStorageClass = (storageClass)=>{
if (!storageClass) return undefined;
const mapping = {
STANDARD: StorageClass.STANDARD,
REDUCED_REDUNDANCY: StorageClass.REDUCED_REDUNDANCY,
STANDARD_IA: StorageClass.STANDARD_IA,
ONEZONE_IA: StorageClass.ONEZONE_IA,
INTELLIGENT_TIERING: StorageClass.INTELLIGENT_TIERING,
GLACIER: StorageClass.GLACIER,
DEEP_ARCHIVE: StorageClass.DEEP_ARCHIVE,
GLACIER_IR: StorageClass.GLACIER_IR
};
return mapping[storageClass];
};
/**
* Maps the provider encryption type to the AWS SDK server-side encryption.
*/ const mapServerSideEncryption = (type)=>{
if (!type) return undefined;
const mapping = {
AES256: ServerSideEncryption.AES256,
'aws:kms': ServerSideEncryption.aws_kms,
'aws:kms:dsse': ServerSideEncryption.aws_kms_dsse
};
return mapping[type];
};
/**
* Converts a tags object to the S3 Tagging header format.
* Format: key1=value1&key2=value2
*/ const formatTagsForHeader = (tags)=>{
if (!tags || Object.keys(tags).length === 0) return undefined;
return Object.entries(tags).map(([key, value])=>`${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&');
};
/**
* Checks if the endpoint appears to be a non-AWS S3-compatible provider.
*/ const isNonAwsEndpoint = (endpoint)=>{
if (!endpoint) return false;
const awsPatterns = [
/\.amazonaws\.com$/i,
/\.amazonaws\.com\.cn$/i
];
return !awsPatterns.some((pattern)=>pattern.test(endpoint));
};
/**
* Validates provider configuration and emits warnings for potential compatibility issues.
*/ const validateProviderConfig = (providerConfig, s3Options)=>{
if (!providerConfig) return;
const endpoint = s3Options?.endpoint?.toString() || '';
const isNonAws = isNonAwsEndpoint(endpoint);
// Warn about AWS-specific features when using non-AWS endpoints
if (isNonAws) {
if (providerConfig.storageClass) {
process.emitWarning(`Storage class '${providerConfig.storageClass}' is AWS S3-specific and may be ignored by your S3-compatible provider.`);
}
if (providerConfig.encryption?.type && providerConfig.encryption.type !== 'AES256') {
process.emitWarning(`Encryption type '${providerConfig.encryption.type}' is AWS S3-specific. Consider using 'AES256' for better compatibility.`);
}
}
// Validate multipart configuration
if (providerConfig.multipart?.partSize) {
const minPartSize = 5 * 1024 * 1024; // 5MB
const maxPartSize = 5 * 1024 * 1024 * 1024; // 5GB
if (providerConfig.multipart.partSize < minPartSize) {
process.emitWarning(`Multipart partSize ${providerConfig.multipart.partSize} is below the minimum of 5MB. This may cause upload failures.`);
}
if (providerConfig.multipart.partSize > maxPartSize) {
process.emitWarning(`Multipart partSize ${providerConfig.multipart.partSize} exceeds the maximum of 5GB. This may cause upload failures.`);
}
}
if (providerConfig.multipart?.queueSize && providerConfig.multipart.queueSize > 16) {
process.emitWarning(`Multipart queueSize ${providerConfig.multipart.queueSize} is high and may cause memory issues. Consider using 4-8.`);
}
};
const getConfig = ({ s3Options, legacyS3Options })=>{
if (Object.keys(legacyS3Options).length > 0) {
process.emitWarning("S3 configuration options passed at root level of the plugin's providerOptions is deprecated and will be removed in a future release. Please wrap them inside the 's3Options:{}' property.");
}
const credentials = extractCredentials({
s3Options,
...legacyS3Options
});
const config = {
...s3Options,
...legacyS3Options,
...credentials ? {
credentials
} : {}
};
if (config.params !== undefined) {
// Only set default ACL when ACL is not explicitly present in params.
// Since April 2023, new AWS S3 buckets have ACLs disabled by default
// ("Bucket owner enforced"). Sending an ACL header to such buckets
// throws AccessControlListNotSupported. To disable ACLs, users should
// simply not include ACL in their params configuration.
if (!('ACL' in config.params)) {
config.params.ACL = ObjectCannedACL.public_read;
}
} else {
throw new Error('Upload AWS S3 provider: `params` are required in the config object');
}
return config;
};
var index = {
init ({ baseUrl, rootPath, s3Options, providerConfig, ...legacyS3Options }) {
// Validate configuration and emit warnings for potential issues
validateProviderConfig(providerConfig, s3Options);
// TODO V5 change config structure to avoid having to do this
const config = getConfig({
s3Options: s3Options,
legacyS3Options
});
const s3Client = new S3Client(config);
const filePrefix = rootPath ? `${rootPath.replace(/\/+$/, '')}/` : '';
const getFileKey = (file)=>{
const sanitizedPath = sanitizePathComponent(file.path);
const path = sanitizedPath ? `${sanitizedPath}/` : '';
const sanitizedHash = sanitizePathComponent(file.hash);
const sanitizedExt = file.ext ? file.ext.replace(/[^a-zA-Z0-9.]/g, '') : '';
return `${filePrefix}${path}${sanitizedHash}${sanitizedExt}`;
};
/**
* Builds the upload parameters including all configured features.
*/ const buildUploadParams = (file, fileKey, customParams = {})=>{
const params = {
Bucket: config.params.Bucket,
Key: fileKey,
Body: file.stream || Buffer.from(file.buffer, 'binary'),
// ACL is optional to support providers like Cloudflare R2 that don't support ACLs.
// Set params.ACL to undefined or omit it entirely to disable ACL headers.
...config.params.ACL ? {
ACL: config.params.ACL
} : {},
ContentType: file.mime
};
// Checksum validation
const checksumAlgorithm = mapChecksumAlgorithm(providerConfig?.checksumAlgorithm);
if (checksumAlgorithm) {
params.ChecksumAlgorithm = checksumAlgorithm;
}
// Conditional writes - prevent overwrite
if (providerConfig?.preventOverwrite) {
params.IfNoneMatch = '*';
}
// Storage class
const storageClass = mapStorageClass(providerConfig?.storageClass);
if (storageClass) {
params.StorageClass = storageClass;
}
// Server-side encryption
if (providerConfig?.encryption) {
const sse = mapServerSideEncryption(providerConfig.encryption.type);
if (sse) {
params.ServerSideEncryption = sse;
if (providerConfig.encryption.kmsKeyId) {
params.SSEKMSKeyId = providerConfig.encryption.kmsKeyId;
}
}
}
// Object tagging
const tagging = formatTagsForHeader(providerConfig?.tags);
if (tagging) {
params.Tagging = tagging;
}
// Merge customParams but preserve critical security parameters
// Bucket, Key, and Body must not be overridden by customParams
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { Bucket, Key, Body, ...safeCustomParams } = customParams;
return {
...params,
...safeCustomParams
};
};
/**
* Constructs the correct file URL.
* Handles S3-compatible providers that return incorrect Location formats.
*
* Some providers (IONOS, some MinIO configs) return malformed Location
* values like "bucket/key" without protocol or domain. For these, we
* fall back to constructing the URL from the endpoint config.
*
* Other providers (AWS, Scaleway, DigitalOcean, Backblaze) return
* correct Location URLs that should be trusted as-is, since they
* already use the correct URL style (virtual-hosted or path-style).
*/ const constructFileUrl = (fileKey, uploadLocation)=>{
// Priority 1: Use baseUrl if configured (CDN or custom domain)
if (baseUrl) {
const cleanBase = baseUrl.replace(/\/+$/, '');
return `${cleanBase}/${fileKey}`;
}
// Priority 2: Use the Location from S3 response if it's a valid URL.
// This preserves correct behavior for providers that return proper URLs
// (AWS, Scaleway, DigitalOcean, Backblaze, etc.), respecting their
// native URL format (virtual-hosted or path-style).
if (uploadLocation && assertUrlProtocol(uploadLocation)) {
return uploadLocation;
}
// Priority 3: Construct URL from endpoint if configured.
// This is a fallback for providers that return malformed Location values
// (e.g., IONOS returns "bucket/key" without protocol for multipart uploads,
// some MinIO configs return similar malformed values).
const endpoint = config.endpoint?.toString();
if (endpoint) {
const endpointUrl = endpoint.startsWith('http') ? endpoint : `https://${endpoint}`;
const cleanEndpoint = endpointUrl.replace(/\/+$/, '');
return `${cleanEndpoint}/${config.params.Bucket}/${fileKey}`;
}
// Priority 4: Prepend https if Location exists but lacks protocol
if (uploadLocation) {
return `https://${uploadLocation}`;
}
// Priority 5: Construct from AWS default pattern
return `https://${config.params.Bucket}.s3.amazonaws.com/${fileKey}`;
};
const upload = async (file, customParams = {})=>{
const fileKey = getFileKey(file);
const params = buildUploadParams(file, fileKey, customParams);
const uploadOptions = {
client: s3Client,
params
};
// Multipart configuration
if (providerConfig?.multipart) {
if (providerConfig.multipart.partSize) {
uploadOptions.partSize = providerConfig.multipart.partSize;
}
if (providerConfig.multipart.queueSize) {
uploadOptions.queueSize = providerConfig.multipart.queueSize;
}
if (providerConfig.multipart.leavePartsOnError !== undefined) {
uploadOptions.leavePartsOnError = providerConfig.multipart.leavePartsOnError;
}
}
const uploadObj = new Upload(uploadOptions);
const result = await uploadObj.done();
file.url = constructFileUrl(fileKey, result.Location ?? '');
if (result.ETag) {
file.etag = result.ETag.replace(/"/g, '');
}
};
/**
* Uploads a file only if the existing object matches the expected ETag.
* This implements optimistic locking to prevent lost updates.
*/ const uploadIfMatch = async (file, expectedETag, customParams = {})=>{
const fileKey = getFileKey(file);
const params = buildUploadParams(file, fileKey, {
...customParams,
IfMatch: expectedETag
});
const uploadObj = new Upload({
client: s3Client,
params
});
const result = await uploadObj.done();
file.url = constructFileUrl(fileKey, result.Location ?? '');
if (result.ETag) {
file.etag = result.ETag.replace(/"/g, '');
}
};
/**
* Retrieves metadata for an object including its ETag.
*/ const getObjectMetadata = async (file)=>{
const command = new HeadObjectCommand({
Bucket: config.params.Bucket,
Key: getFileKey(file)
});
const response = await s3Client.send(command);
return {
etag: response.ETag?.replace(/"/g, ''),
contentLength: response.ContentLength,
contentType: response.ContentType,
lastModified: response.LastModified,
storageClass: response.StorageClass,
serverSideEncryption: response.ServerSideEncryption
};
};
/**
* Checks if an object exists in the bucket.
*/ const objectExists = async (file)=>{
try {
await getObjectMetadata(file);
return true;
} catch (error) {
if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
return false;
}
throw error;
}
};
return {
/**
* Returns whether the bucket is configured with private ACL.
*/ isPrivate () {
return config.params.ACL === 'private';
},
/**
* Returns the current provider configuration.
*/ getProviderConfig () {
return providerConfig;
},
/**
* Generates a signed URL for accessing a private object.
*/ async getSignedUrl (file, customParams) {
if (!isUrlFromBucket(file.url, config.params.Bucket, baseUrl)) {
return {
url: file.url
};
}
const fileKey = getFileKey(file);
// Spread customParams first, then override with secure values
// This prevents malicious override of Bucket and Key
const url = await getSignedUrl(s3Client, new GetObjectCommand({
...customParams,
Bucket: config.params.Bucket,
Key: fileKey
}), {
expiresIn: getOr(15 * 60, [
'params',
'signedUrlExpires'
], config)
});
return {
url
};
},
/**
* Uploads a file using streaming.
*/ uploadStream (file, customParams = {}) {
return upload(file, customParams);
},
/**
* Uploads a file to S3.
*/ upload (file, customParams = {}) {
return upload(file, customParams);
},
/**
* Replaces an existing object in S3. Since `PutObject` with the same key
* overwrites the existing object, this is just an upload of the new file
* — and if the key changed, we delete the old object afterwards so we
* never leave the bucket in a state where the asset is missing.
*/ async replaceStream (newFile, oldFile, customParams = {}) {
const newKey = getFileKey(newFile);
const oldKey = getFileKey(oldFile);
await upload(newFile, customParams);
if (newKey !== oldKey) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { Bucket, Key, ...safeParams } = customParams;
const command = new DeleteObjectCommand({
...safeParams,
Bucket: config.params.Bucket,
Key: oldKey
});
await s3Client.send(command);
}
},
async replace (newFile, oldFile, customParams = {}) {
const newKey = getFileKey(newFile);
const oldKey = getFileKey(oldFile);
await upload(newFile, customParams);
if (newKey !== oldKey) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { Bucket, Key, ...safeParams } = customParams;
const command = new DeleteObjectCommand({
...safeParams,
Bucket: config.params.Bucket,
Key: oldKey
});
await s3Client.send(command);
}
},
/**
* Uploads a file only if it matches the expected ETag (optimistic locking).
* Throws PreconditionFailed error if ETag does not match.
*/ uploadIfMatch (file, expectedETag, customParams = {}) {
return uploadIfMatch(file, expectedETag, customParams);
},
/**
* Retrieves object metadata including ETag.
*/ getObjectMetadata (file) {
return getObjectMetadata(file);
},
/**
* Checks if an object exists in the bucket.
*/ objectExists (file) {
return objectExists(file);
},
/**
* Deletes an object from S3.
*/ delete (file, customParams = {}) {
// Spread customParams first, then override with secure values
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { Bucket, Key, ...safeParams } = customParams;
const command = new DeleteObjectCommand({
...safeParams,
Bucket: config.params.Bucket,
Key: getFileKey(file)
});
return s3Client.send(command);
}
};
}
};
export { index as default };
//# sourceMappingURL=index.mjs.map