-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: support bucket region for all routes
- Loading branch information
Showing
15 changed files
with
7,522 additions
and
25,054 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
const { getS3Client, getProperty } = require('../helpers'); | ||
|
||
class BaseController { | ||
constructor(config, context) { | ||
this.config = config; | ||
this.context = context; | ||
|
||
if (!this.config.endpoints || Object.keys(this.config.endpoints).length === 0) { | ||
throw new Error('BaseController requires a valid endpoints configuration.'); | ||
} | ||
} | ||
|
||
getS3Client(region) { | ||
const endpoint = this.config.endpoints[region]; | ||
|
||
if (!endpoint) { | ||
throw new Error(`No endpoint configured for region: ${region}`); | ||
} | ||
|
||
return getS3Client(endpoint); | ||
} | ||
|
||
stringArg(request, paramPath, defaultValue = null) { | ||
const value = getProperty(request.input.args, paramPath) || defaultValue; | ||
|
||
if (!value) { | ||
throw new this.context.errors.BadRequestError(`Missing argument: "${paramPath}"`); | ||
} | ||
if (typeof value !== 'string') { | ||
throw new this.context.errors.BadRequestError(`Invalid value for "${paramPath}"`); | ||
} | ||
|
||
return value; | ||
} | ||
} | ||
|
||
module.exports = BaseController; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,164 @@ | ||
const BaseController = require('./BaseController'); | ||
|
||
class BucketController extends BaseController { | ||
/** | ||
* Validate the given bucket name to match S3's naming rules. | ||
* @param {string} bucketName - The name of the bucket to validate. | ||
* @param {boolean} disableDotsInName - If true, disallow dots in the bucket name. | ||
*/ | ||
_validateBucketName(bucketName, disableDotsInName = false) { | ||
const bucketNameRegex = disableDotsInName | ||
? /(?!(^xn--|.+-s3alias$))^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/ | ||
: /(?!(^((2(5[0-5]|[0-4][0-9])|[01]?[0-9]{1,2})\.){3}(2(5[0-5]|[0-4][0-9])|[01]?[0-9]{1,2})$|^xn--|.+-s3alias$))^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/; | ||
|
||
if (!bucketNameRegex.test(bucketName)) { | ||
throw new this.context.errors.BadRequestError(`Invalid bucket name format: "${bucketName}"`); | ||
} | ||
} | ||
|
||
async _bucketExists(bucketName, bucketRegion) { | ||
const s3 = this.getS3Client(bucketRegion); | ||
|
||
try { | ||
await s3.headBucket({ Bucket: bucketName }).promise(); | ||
return true; | ||
} catch (error) { | ||
if (error.code === 'NotFound') { | ||
return false; | ||
} | ||
this.context.log.error(`Error checking bucket existence "${bucketName}": ${error.message}`); | ||
throw error; | ||
} | ||
} | ||
|
||
/** | ||
* Create a new bucket with optional configurations. | ||
*/ | ||
async create(request) { | ||
const bucketName = this.stringArg(request, 'bucketName', this.config.bucketName); | ||
const bucketRegion = this.stringArg(request, 'bucketRegion', this.defaultRegion); | ||
let bucketCORS, bucketOptions = {}; | ||
if(request.input.body && request.input.body.options) { | ||
bucketOptions = request.input.body.options || { ACL: 'public-read' }; | ||
bucketCORS = request.input.body.cors || { | ||
CORSRules: [ | ||
{ | ||
AllowedHeaders: ['*'], | ||
AllowedMethods: ['GET', 'POST', 'PUT'], | ||
AllowedOrigins: ['*'], | ||
}, | ||
], | ||
}; | ||
|
||
} | ||
|
||
const s3 = this.getS3Client(bucketRegion); | ||
|
||
// Check if bucket already exists | ||
if (await this._bucketExists(bucketName, bucketRegion)) { | ||
throw new this.context.errors.BadRequestError(`Bucket "${bucketName}" already exists.`); | ||
} | ||
|
||
// Validate bucket name | ||
this._validateBucketName(bucketName); | ||
|
||
try { | ||
// Create the bucket | ||
await s3.createBucket({ Bucket: bucketName, ...bucketOptions }).promise(); | ||
|
||
// Set CORS configuration if not using Minio | ||
if (!this.config.isMinio) { | ||
await s3.putBucketCors({ | ||
Bucket: bucketName, | ||
CORSConfiguration: bucketCORS, | ||
}).promise(); | ||
} | ||
} catch (error) { | ||
this.context.log.error(`Error creating bucket "${bucketName}": ${error.message}`); | ||
throw error; | ||
} | ||
|
||
return { name: bucketName, region: bucketRegion }; | ||
} | ||
|
||
/** | ||
* Delete an empty bucket. | ||
*/ | ||
async delete(request) { | ||
const bucketName = this.stringArg(request, 'bucketName'); | ||
const bucketRegion = this.stringArg(request, 'bucketRegion', this.defaultRegion); | ||
const s3 = this.getS3Client(bucketRegion); | ||
|
||
try { | ||
await s3.deleteBucket({ Bucket: bucketName }).promise(); | ||
} catch (error) { | ||
this.context.log.error(`Error deleting bucket "${bucketName}": ${error.message}`); | ||
throw error; | ||
} | ||
|
||
return { message: `Bucket "${bucketName}" deleted.` }; | ||
} | ||
|
||
/** | ||
* Check if a bucket exists. | ||
*/ | ||
/** | ||
* Check if a bucket exists via the route. | ||
*/ | ||
async exists(request) { | ||
const bucketName = this.stringArg(request, 'bucketName', this.config.bucketName); | ||
const bucketRegion = this.stringArg(request, 'bucketRegion', this.defaultRegion); | ||
|
||
const exists = await this._bucketExists(bucketName, bucketRegion); | ||
return { exists }; | ||
} | ||
|
||
/** | ||
* Set a policy on a bucket. | ||
*/ | ||
async setPolicy(request) { | ||
const bucketName = this.stringArg(request, 'bucketName', this.config.bucketName); | ||
const bucketRegion = this.stringArg(request, 'bucketRegion', this.defaultRegion); | ||
const policy = request.input.body.policy; | ||
|
||
if (!policy) { | ||
throw new this.context.errors.BadRequestError('Missing "policy" parameter.'); | ||
} | ||
|
||
const s3 = this.getS3Client(bucketRegion); | ||
|
||
try { | ||
await s3.putBucketPolicy({ | ||
Bucket: bucketName, | ||
Policy: JSON.stringify(policy), | ||
}).promise(); | ||
} catch (error) { | ||
this.context.log.error(`Error setting policy on bucket "${bucketName}": ${error.message}`); | ||
throw error; | ||
} | ||
|
||
return { message: `Policy applied to bucket "${bucketName}".` }; | ||
} | ||
|
||
/** | ||
* Enable public access by removing BlockPublicAccess restrictions. | ||
*/ | ||
async enablePublicAccess(request) { | ||
const bucketName = this.stringArg(request, 'bucketName', this.config.bucketName); | ||
const bucketRegion = this.stringArg(request, 'bucketRegion', this.defaultRegion); | ||
const s3 = this.getS3Client(bucketRegion); | ||
|
||
if (!this.config.isMinio) { | ||
try { | ||
await s3.deletePublicAccessBlock({ Bucket: bucketName }).promise(); | ||
} catch (error) { | ||
this.context.log.error(`Error enabling public access on bucket "${bucketName}": ${error.message}`); | ||
throw error; | ||
} | ||
} | ||
|
||
return { message: `Public access enabled for bucket "${bucketName}".` }; | ||
} | ||
} | ||
|
||
module.exports = BucketController; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
const BaseController = require('./BaseController'); | ||
const listAllObjects = require('../helpers'); | ||
|
||
class FileController extends BaseController { | ||
/** | ||
* Get the list of file keys from the S3 bucket. | ||
*/ | ||
async getFilesKeys(request) { | ||
const bucketName = this.stringArg(request, 'bucketName', this.config.bucketName); | ||
const region = this.stringArg(request, 'bucketRegion', this.defaultRegion); | ||
const s3 = this.getS3Client(region); | ||
|
||
const files = await listAllObjects(s3, { Bucket: bucketName }); | ||
return { | ||
files: files.map(file => ({ | ||
Key: file.Key, | ||
LastModified: file.LastModified, | ||
Size: file.Size, | ||
})), | ||
}; | ||
} | ||
|
||
/** | ||
* Delete a file from the S3 bucket. | ||
*/ | ||
async fileDelete(request) { | ||
const fileKey = this.stringArg(request, 'fileKey'); | ||
const bucketName = this.stringArg(request, 'bucketName', this.config.bucketName); | ||
const region = this.stringArg(request, 'bucketRegion', this.defaultRegion); | ||
const s3 = this.getS3Client(region); | ||
|
||
const fileExists = await this._fileExists(s3, bucketName, fileKey); | ||
|
||
if (!fileExists) { | ||
throw new this.context.errors.NotFoundError(`File "${fileKey}" does not exist.`); | ||
} | ||
|
||
await s3.deleteObject({ Bucket: bucketName, Key: fileKey }).promise(); | ||
return { message: `File "${fileKey}" deleted.` }; | ||
} | ||
|
||
/** | ||
* Get the URL of a specific file from the S3 bucket. | ||
*/ | ||
async fileGetUrl(request) { | ||
const fileKey = this.stringArg(request, 'fileKey'); | ||
const bucketName = this.stringArg(request, 'bucketName', this.config.bucketName); | ||
const region = this.stringArg(request, 'bucketRegion', this.defaultRegion); | ||
|
||
return { | ||
fileUrl: this._getUrl(bucketName, fileKey, region), | ||
}; | ||
} | ||
|
||
/** | ||
* Check if a file exists in the S3 bucket. | ||
*/ | ||
async _fileExists(s3, bucketName, fileKey) { | ||
try { | ||
await s3.headObject({ Bucket: bucketName, Key: fileKey }).promise(); | ||
return true; | ||
} catch (error) { | ||
if (error.code === 'NotFound') { | ||
return false; | ||
} | ||
throw error; | ||
} | ||
} | ||
|
||
/** | ||
* Construct the file's public or base URL. | ||
* @param {string} bucketName | ||
* @param {string} fileKey | ||
* @param {string} region | ||
* @returns {string} | ||
*/ | ||
_getUrl(bucketName, fileKey, region) { | ||
const endpoint = this.config.endpoints[region]; | ||
if (!endpoint) { | ||
throw new Error(`No endpoint configured for region: ${region}`); | ||
} | ||
return `${endpoint}/${bucketName}/${fileKey}`; | ||
} | ||
} | ||
|
||
module.exports = FileController; |
Oops, something went wrong.