UNPKG

@coko/server

Version:

Reusable server for use by Coko's projects

244 lines 9.69 kB
"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