UNPKG

node-bunny-storage

Version:

A simple wrapper for the bunnyCDN storage api

449 lines (392 loc) 18.4 kB
import axios from 'axios'; import axiosRetry from 'axios-retry'; import fse from "fs-extra"; import path from 'path'; import {createLogger, format, transports} from 'winston'; import {Sema} from "async-sema"; const logFormat = format.printf(({level, message, label, timestamp}) => { return `${format.colorize().colorize(level, '[' + level + ']')} ${format.colorize().colorize('debug', timestamp)}: ${message}`; }); class BunnyCDNStorage { /** * @param {object} options The options object. * @param {string} options.accessKey Your storage zone API access key. This is also your ftp password shown in the bunny dashboard. * @param {string} options.storageZoneName The name of your storage zone. * @param {number} [options.concurrency=16] The max number of concurrent connections used for listing files (when recursive is true) as well for uploading and downloading folders. Defaults to 16. * @param {number} [options.retryCount=2] The number of times to retry a failed request. * @param {string} [options.logLevel='error'] The log level for this module. Can be 'info', 'error' or 'silent'. Defaults to 'error'. */ constructor({accessKey, storageZoneName, concurrency = 16, retryCount = 2, logLevel = 'error'}) { this.accessKey = accessKey; this.storageZoneName = storageZoneName; this.baseURL = 'https://storage.bunnycdn.com/'; this.sema = new Sema(concurrency); this.logger = createLogger({ level: logLevel, format: format.combine( format.timestamp(), logFormat ), transports: [ new transports.Console({ silent: logLevel === 'silent' }) ] }); // Setup axios-retry axiosRetry(axios, { retries: retryCount, retryDelay: (numberOfRetries) => { return numberOfRetries * 2000; } }); } /** * Get the file path for a file. * @param {string} directory - The remote directory path. * @param {string} fileName - The name of the file. * @returns {string} The remote file path. * @private */ _getFilePath(directory, fileName) { try { let filePath = ''; if (directory && directory !== '/') { if (directory.startsWith('/')) directory = directory.slice(1); if (directory.endsWith('/')) directory = directory.slice(0, -1); filePath += `${directory}/`; } if (fileName) { if (fileName.startsWith('/')) fileName = fileName.slice(1); if (fileName.endsWith('/')) fileName = fileName.slice(0, -1); filePath += fileName; } return filePath; } catch (error) { this.logger.error(`Failed to generate file path for ${directory} and ${fileName}: ${error}`); throw error; } } /** * Generate the full storage URL for a file for the BunnyCDN API. * @param {string} directory - The remote directory path. * @param {string} [fileName] - The name of the file. * @return {string} The remote storage URL. * @private */ _getFullStorageUrl(directory, fileName) { try { const filePath = this._getFilePath(directory, fileName); return `${this.baseURL}${this.storageZoneName}/` + filePath; } catch (error) { this.logger.error(`Failed to generate full storage URL for ${directory} and ${fileName}: ${error}`); throw error; } } /** * Get the remote path for a file without the storage zone name. Does not include the file name. * @param file * @returns {string} The remote path without the storage zone name. Does not include the file name. */ getRemotePathFromFileWithoutStorageZone(file) { try { let remotePath = file.Path; if (remotePath.startsWith('/' + this.storageZoneName + '/')) remotePath = remotePath.slice(this.storageZoneName.length + 2); if (!remotePath) return '/'; return remotePath; } catch (error) { this.logger.error(`Failed to get remote path from file ${file}: ${error}`); throw error; } } /** * List all files in a directory. * @param {object} options The options object. * @param {string} [options.remoteDirectory='/'] The directory path. Leave blank or use '/' to list files in the root directory. * @param {boolean} [options.recursive=false] Should the list go through each subdirectory recursively. * @param {boolean} [options.includeFolders=false] Should folders be included in the list. * @param {string[]} [options.excludedFileTypes=[]] Define file types that should not be included, e.g. ['.pdf', '.jpg'] * @param {function} options.fileFilter Can be used to exclude individual files. The function receives the remote filepath (without the storage zone) as a parameter. If the callback returns false, the file will not be included. * @param {boolean} [_isRecursiveCall=false] Used internally to keep track of recursive calls. Only needed internally. */ async listFiles({ remoteDirectory = '/', recursive = false, includeFolders = false, excludedFileTypes = [], fileFilter }, _isRecursiveCall = false) { try { const url = this._getFullStorageUrl(remoteDirectory); this.logger.info(`Listing files in ${url}`); const response = await axios.get(url, { headers: { 'AccessKey': this.accessKey, 'Content-Type': 'application/json' } }); const files = []; const pushFileIfAllowed = (file) => { const ext = path.extname(file.ObjectName); if (excludedFileTypes?.length && excludedFileTypes.includes(ext)) { this.logger.info(`Excluding file due to file type: ${file.ObjectName}`); return; } if (fileFilter) { const shouldInclude = fileFilter(this.getRemotePathFromFileWithoutStorageZone(file) + file.ObjectName); if (!shouldInclude) { this.logger.info(`Excluding file due to fileFilter: ${file.ObjectName}`); return; } } files.push(file); } for (const file of response.data) { if (file.IsDirectory) { if (recursive) { const subFiles = await this.listFiles({ remoteDirectory: this.getRemotePathFromFileWithoutStorageZone(file) + file.ObjectName, recursive, includeFolders, excludedFileTypes, fileFilter }, true); files.push(...subFiles); } if (includeFolders) files.push(file); } else { pushFileIfAllowed(file) } } if (!_isRecursiveCall) this.logger.info(`Number of found files totally ${files.length}`); return files; } catch (error) { this.logger.error(`Failed to list files in ${remoteDirectory}: ${error}. URL: ${this._getFullStorageUrl(remoteDirectory)}`); throw error; } } /** * Upload a file to BunnyCDN storage. * @param {object} options The options object. * @param {string} [options.localFilePath='.'] - The local file path. Defaults to the current directory. * @param {string} [options.remoteDirectory='/'] - The remote directory path. Leave blank or use '/' to upload to the root directory. */ async uploadFile({localFilePath = '.', remoteDirectory = '/'}) { try { const fileExists = await fse.pathExists(localFilePath); if (!fileExists) { this.logger.error(`Upload failed: File does not exist: ${localFilePath}`); throw new Error(`Upload failed: File does not exist: ${localFilePath}`); } this.logger.info(`Uploading ${localFilePath} to ${remoteDirectory}`); const fileData = fse.createReadStream(localFilePath); const fileName = path.basename(localFilePath); // Extract the file name from the local file path const url = this._getFullStorageUrl(remoteDirectory, fileName); const config = { headers: { 'AccessKey': this.accessKey, 'Content-Type': 'application/octet-stream' } }; return await axios.put(url, fileData, config); } catch (error) { this.logger.error(`uploadFile Error: ${error}, localFilePath: ${localFilePath}, remoteDirectory: ${remoteDirectory}. URL: ${this._getFullStorageUrl(remoteDirectory, path.basename(localFilePath))}`); throw error; } } /** * Download a file from BunnyCDN storage. * @param {object} options The options object. * @param {string} [options.remoteDirectory='/'] - The remote directory path. Leave blank or use '/' to download a file from the root directory. * @param {string} options.fileName - The name of the file to download. * @param {string} [options.localDirectory='.'] - The local directory to download the file to. Defaults to the current directory. * @returns {Promise<string>} - Returns a promise that resolves with the local file path of the downloaded file. */ async downloadFile({remoteDirectory = '/', fileName, localDirectory = '.'}) { try { if (!fileName) { this.logger.error('downloadFile: No file name provided'); throw new Error('downloadFile: No file name provided'); } this.logger.info(`Downloading ${fileName} from ${remoteDirectory}`); const url = this._getFullStorageUrl(remoteDirectory, fileName); const response = await axios.get(url, { responseType: 'stream', headers: { 'AccessKey': this.accessKey } }); const localPath = path.join(localDirectory, fileName); await fse.ensureDir(localDirectory); // Create a writable stream and pipe the response data to it const fileStream = fse.createWriteStream(localPath); response.data.pipe(fileStream); // Return a promise that resolves when the file has finished downloading return new Promise((resolve, reject) => { fileStream.on('finish', () => { this.logger.info(`Downloaded ${fileName} to ${localPath}`); resolve(localPath); }); fileStream.on('error', () => { this.logger.error(`Error downloading ${fileName} to ${localPath}. URL: ${url}`); reject(localPath); }); }); } catch (error) { this.logger.error(`downloadFile Error:: ${error}, remoteDirectory: ${remoteDirectory}, fileName: ${fileName}, localDirectory: ${localDirectory}, url: ${this._getFullStorageUrl(remoteDirectory, fileName)}`); throw error; } } /** * Delete a file from BunnyCDN storage. * @param {object} options The options object. * @param {string} [options.remoteDirectory='/'] - The remote directory path. Leave blank or use '/' to delete a file from the root directory. * @param {string} options.fileName - The name of the file to delete. If it is a directory, the directory and all files in the directory will be deleted. If remoteDirectory and fileName are blank, all files in the storage zone will be deleted. */ async delete({remoteDirectory = '/', fileName}) { if (!fileName) { this.logger.error(`delete: No file name provided, remoteDirectory: ${remoteDirectory}`); throw new Error(`delete: No file name provided, remoteDirectory: ${remoteDirectory}`); } try { this.logger.info(`Deleting ${fileName} from ${remoteDirectory}`); const url = this._getFullStorageUrl(remoteDirectory, fileName); await axios.delete(url, { headers: { 'AccessKey': this.accessKey, 'Content-Type': 'application/json' } }); this.logger.info(`Deleted ${fileName} from ${remoteDirectory}, it's url was ${url}`); return url; } catch (error) { this.logger.error(`delete Error: ${error}, remoteDirectory: ${remoteDirectory}, file: ${fileName}, url: ${this._getFullStorageUrl(remoteDirectory, fileName)}`); throw error; } } /** * Upload many files to BunnyCDN storage. * @param {object} options The options object. * @param {string} [options.localDirectory ='./']- The local directory path. Defaults to the current directory. * @param {string} [options.remoteDirectory='/'] - The remote directory path. Leave blank or use '/' to upload files to the root directory. * @param {boolean} [options.recursive=false] - Include local subdirectories. * @param {string[]} [options.excludedFileTypes=[]] - File types to exclude from the upload. * @param {function} options.fileFilter - Can be used to exclude individual files. The function receives the filepath as a parameter. If the callback returns false, the file will not be uploaded. */ async uploadFolder({ localDirectory = './', remoteDirectory = '/', recursive = false, excludedFileTypes = [], fileFilter }) { try { const dirExists = await fse.pathExists(localDirectory); if (!dirExists) { this.logger.error(`uploadFolder failed: local directory does not exist: ${localDirectory}`); throw new Error(`uploadFolder failed: local directory does not exist: ${localDirectory}`); } this.logger.info(`Uploading files from ${localDirectory} to ${remoteDirectory}`); // Read all files from the local directory let items = await fse.readdir(localDirectory); const promises = []; for (const item of items) { const fullPath = path.join(localDirectory, item); const relativePath = path.relative(localDirectory, fullPath); const itemStat = await fse.stat(fullPath); if (itemStat.isDirectory()) { if (recursive) { const newRemoteDirectory = path.join(remoteDirectory, relativePath); promises.push( this.uploadFolder({ localDirectory: fullPath, remoteDirectory: newRemoteDirectory, recursive, excludedFileTypes, fileFilter }) ); } } else { // Filter out excluded file types if (excludedFileTypes?.length) { const ext = path.extname(relativePath); if (excludedFileTypes.includes(ext)) continue; } // Filter out files using the fileFilter function, skip if it returns false if (fileFilter) { if (!fileFilter(relativePath)) continue; } promises.push( this.sema.acquire().then(() => { return this.uploadFile({ localFilePath: fullPath, remoteDirectory }).then(() => { this.sema.release(); return fullPath; // Resolve with the uploaded file's path }); }) ); } } const uploadedFiles = await Promise.all(promises); this.logger.info(`Uploaded ${uploadedFiles.length} files from ${localDirectory} to ${remoteDirectory}`); return uploadedFiles; } catch (error) { this.logger.error(`uploadFolder Error: ${error}, localDirectory: ${localDirectory}, remoteDirectory: ${remoteDirectory}`); throw error; } } /** * Download a folder from BunnyCDN storage. * @param {object} options The options object. * @param {string} [options.remoteDirectory='/'] The remote directory path. Leave blank or use '/' to download files from the root directory. * @param {string} [options.localDirectory='.'] The local directory path where the downloaded files should be saved. Defaults to the current directory. * @param {boolean} [options.recursive=fales] Should the operation be performed recursively. * @param {string[]} [options.excludedFileTypes=[]] Define file types that should not be downloaded, e.g. ['.pdf', '.jpg'] * @param {function} options.fileFilter Can be used to exclude individual files. The function receives the remote filepath (without the storage zone) as a parameter. If the callback returns false, the file will not be downloaded. */ async downloadFolder({ remoteDirectory = '/', localDirectory = '.', recursive = false, excludedFileTypes = [], fileFilter }) { try { const files = await this.listFiles({ remoteDirectory, recursive, excludedFileTypes, fileFilter }); const totalFilesToDownload = files.length; this.logger.info(`Downloading ${totalFilesToDownload} files from ${remoteDirectory} to ${localDirectory}`); let downloadedCount = 0; const promises = []; for (const file of files) { const remotePath = this.getRemotePathFromFileWithoutStorageZone(file); let downloadDestination = localDirectory; if (recursive && remotePath) downloadDestination = path.join(localDirectory, remotePath); promises.push( this.sema.acquire().then(() => { return this.downloadFile({ remoteDirectory: remotePath, fileName: file.ObjectName, localDirectory: downloadDestination }).then((downloadPath) => { downloadedCount++; this.logger.info(`Downloaded ${downloadedCount} of ${totalFilesToDownload} files`); this.sema.release(); return downloadPath; }) }) ); } const downloadPaths = await Promise.all(promises); this.logger.info(`Downloaded ${downloadedCount} files from ${remoteDirectory} to ${localDirectory}`); return downloadPaths; } catch (error) { this.logger.error(`downloadFolder Error: ${error}, remoteDirectory: ${remoteDirectory}, localDirectory: ${localDirectory}`); throw error; } } } export default BunnyCDNStorage;