@coko/server
Version:
Reusable server for use by Coko's projects
244 lines • 9.69 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const stream_1 = require("stream");
const path_1 = __importDefault(require("path"));
const crypto_1 = __importDefault(require("crypto"));
const fs_extra_1 = __importDefault(require("fs-extra"));
const mime_types_1 = __importDefault(require("mime-types"));
const client_s3_1 = require("@aws-sdk/client-s3");
const lib_storage_1 = require("@aws-sdk/lib-storage");
const s3_request_presigner_1 = require("@aws-sdk/s3-request-presigner");
const config_1 = __importDefault(require("../configManager/config"));
const filesystem_1 = require("../utils/filesystem");
const Image_1 = __importDefault(require("./Image"));
const streamToString = (stream) => {
const chunks = [];
return new Promise((resolve, reject) => {
stream.on('data', chunk => chunks.push(Buffer.from(chunk)));
stream.on('error', err => reject(err));
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
};
class FileStorage {
bucket;
imageConversionToSupportedFormatMapper = { eps: 'svg' };
s3;
#s3ForSigning;
separateDeleteOperations;
url;
publicUrl;
constructor(connectionConfig, properties) {
const DEFAULT_REGION = 'us-east-1';
const configToUse = connectionConfig || config_1.default.get('fileStorage');
const { accessKeyId, secretAccessKey, bucket, region, url, publicUrl, s3ForcePathStyle, s3SeparateDeleteOperations, } = configToUse;
this.url = url;
this.publicUrl = publicUrl || url;
this.bucket = bucket;
const forcePathStyle = s3ForcePathStyle ?? true;
const s3config = {
credentials: null,
forcePathStyle,
endpoint: url,
region: region || DEFAULT_REGION,
};
/**
* These are optional as authentication in AWS could happen through the
* existence of environment variables or IAM roles.
*
* If the environment is not set up correctly, startup will fail when
* checking the file storage connection.
*/
if (accessKeyId || secretAccessKey) {
s3config.credentials = {
accessKeyId,
secretAccessKey,
};
}
this.s3 = new client_s3_1.S3(s3config);
this.#s3ForSigning =
this.publicUrl !== this.url
? new client_s3_1.S3({ ...s3config, endpoint: this.publicUrl })
: this.s3;
this.separateDeleteOperations = s3SeparateDeleteOperations;
/**
* Override some values only for testing purposes.
* This is fine, as we're not exporting the contructor from the lib.
*/
if (properties) {
Object.keys(properties).forEach(key => {
this[key] = properties[key];
});
}
}
async #get(key) {
const command = new client_s3_1.GetObjectCommand({
Bucket: this.bucket,
Key: key,
});
try {
return this.s3.send(command);
}
catch (e) {
throw new Error(`Cannot retrieve item ${key} from bucket ${this.bucket}: ${e.message}`);
}
}
async #getFileInfo(key) {
const params = {
Bucket: this.bucket,
Key: key,
};
return this.s3.headObject(params);
}
async #handleImageUpload(fileStream, hashedFilename, isPublic) {
const randomHash = crypto_1.default.randomBytes(6).toString('hex');
const tempDir = path_1.default.join(filesystem_1.tempFolderPath, randomHash);
await fs_extra_1.default.ensureDir(tempDir);
const originalFilePath = path_1.default.join(randomHash, hashedFilename);
await (0, filesystem_1.writeFileToTemp)(fileStream, originalFilePath);
const image = new Image_1.default({
filename: hashedFilename,
dir: tempDir,
});
const dataToUpload = await image.generateVersions();
const storedObjects = await Promise.all(dataToUpload.map(async (item) => {
const uploadedKey = await this.#uploadFileHandler(fs_extra_1.default.createReadStream(item.path), item.filename, item.mimetype, isPublic);
const uploaded = {
key: uploadedKey,
imageMetadata: {
density: item.imageMetadata.density,
height: item.imageMetadata.height,
space: item.imageMetadata.space,
width: item.imageMetadata.width,
},
size: item.size,
extension: item.extension,
type: item.type,
mimetype: item.mimetype,
};
return uploaded;
}));
await fs_extra_1.default.remove(tempDir);
return storedObjects;
}
async #uploadFileHandler(fileStream, filename, mimetype, isPublic) {
const params = {
Bucket: this.bucket,
Key: filename, // file name you want to save as
Body: fileStream,
ContentType: mimetype,
};
if (isPublic)
params.ACL = 'public-read';
const upload = new lib_storage_1.Upload({
client: this.s3,
params,
});
// upload.on('httpUploadProgress', progress => {
// console.log(progress)
// })
const data = await upload.done();
return data.Key;
}
// object keys is an array
async delete(objectKeys) {
if (!objectKeys || (Array.isArray(objectKeys) && objectKeys.length === 0)) {
throw new Error('No keys provided. Nothing to delete.');
}
// delete a single key
if (!Array.isArray(objectKeys)) {
const params = { Bucket: this.bucket, Key: objectKeys };
return this.s3.deleteObject(params);
}
// gcp compatibility - does not support batch delete
if (this.separateDeleteOperations) {
return Promise.all(objectKeys.map(async (objectKey) => {
const params = { Bucket: this.bucket, Key: objectKey };
return this.s3.deleteObject(params);
}));
}
const params = {
Bucket: this.bucket,
Delete: {
Objects: objectKeys.map(k => ({ Key: k })),
Quiet: false,
},
};
return this.s3.deleteObjects(params);
}
async download(key, localPath) {
const item = await this.#get(key);
const body = item.Body;
if (!body) {
throw new Error(`Item ${key} has no body.`);
}
if (!(body instanceof stream_1.Readable)) {
throw new Error(`Item ${key} body is not a Node.js Readable stream.`);
}
try {
const writeStream = fs_extra_1.default.createWriteStream(localPath);
await new Promise((resolve, reject) => {
body
.on('error', reject) // catch stream download errors
.pipe(writeStream)
.on('error', reject) // catch disk write errors
.on('finish', () => resolve(undefined));
});
}
catch (e) {
throw new Error(`Error writing item ${key} to disk. ${e.message}`);
}
}
async getFileContent(objectKey) {
const data = await this.#get(objectKey);
return streamToString(data.Body);
}
async getURL(objectKey, options = {}) {
const { expiresIn } = options;
const s3Params = {
expiresIn: expiresIn || 24 * 3600, // 1 day
};
const command = new client_s3_1.GetObjectCommand({
Bucket: this.bucket,
Key: objectKey,
});
return (0, s3_request_presigner_1.getSignedUrl)(this.#s3ForSigning, command, s3Params);
}
getPublicURL(objectKey) {
return `${this.publicUrl}/${this.bucket}/${objectKey}`;
}
async healthCheck() {
return this.s3.headBucket({ Bucket: this.bucket });
}
async list() {
return this.s3.listObjects({ Bucket: this.bucket });
}
async upload(fileStream, filename, options = {}) {
if (!filename)
throw new Error('filename is required');
const mimetype = mime_types_1.default.lookup(filename) || 'application/octet-stream';
const { forceObjectKeyValue } = options;
const hash = crypto_1.default.randomBytes(6).toString('hex');
const extension = path_1.default.extname(filename).slice(1);
const hashedFilename = forceObjectKeyValue || `${hash}.${extension}`;
const isPublic = options.public || false;
const shouldConvert = !!this.imageConversionToSupportedFormatMapper[extension];
const isImage = mimetype.match(/^image\//) || shouldConvert;
if (isImage)
return this.#handleImageUpload(fileStream, hashedFilename, isPublic);
const storedObjectKey = await this.#uploadFileHandler(fileStream, hashedFilename, mimetype, isPublic);
const { ContentLength } = await this.#getFileInfo(storedObjectKey);
const storedObject = {
key: storedObjectKey,
type: 'original',
size: ContentLength,
extension: extension,
mimetype: mimetype,
};
return [storedObject];
}
}
exports.default = FileStorage;
//# sourceMappingURL=FileStorage.js.map