Skip to content

Commit

Permalink
feat: support bucket region for all routes
Browse files Browse the repository at this point in the history
  • Loading branch information
Juiced66 committed Nov 25, 2024
1 parent fd5ab37 commit 1158a80
Show file tree
Hide file tree
Showing 15 changed files with 7,522 additions and 25,054 deletions.
746 changes: 80 additions & 666 deletions lib/S3Plugin.js

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions lib/controllers/BaseController.js
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;
164 changes: 164 additions & 0 deletions lib/controllers/BucketController.js
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;
86 changes: 86 additions & 0 deletions lib/controllers/FileController.js
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;
Loading

0 comments on commit 1158a80

Please sign in to comment.