const https = require("https");
const path = require("path");
const fs = require("fs");
const DEFAULT_TOKEN = process.env[process.env.SLACK_DOWNLOAD_ACCESS_TOKEN_CHOICE || "SLACK_BOT_USER_OAUTH_ACCESS_TOKEN"];
const DOWNLOADS_FOLDER = path.resolve(__dirname, "downloads");
const FAILED_DOWNLOAD_IMAGE_PATH = path.resolve(__dirname, "placeholders", "ERROR.png");
// Arbitrary. Used as the max number of attempts getValidFileName has for finding an available path to download a file to
// If the program doesn't crash or reject in-between the download and the deletion, this will most likely never be reached if kept over 10
const FILE_NAME_ITERATOR_LIMIT = 200;
const pendingDownloads = [];
/**
* Data about a locally downloaded file
* @typedef FileData
* @property {string} path Absolute path to local file
* @property {string} storedAs The name of the local file
* @property {string} extension File extension of the file
* @property {number} size Size of the object in megabytes
* @property {string} title The original title of the file
* @property {string} name The original name of the file
* @property {Object} original The original metadata of the file (Slack File Object)
* @property {string} id The ID of the file object from Slack
*/
/**
* Handles downloading and deleting files from folders (Especially the designated downloads folder)
* @module fileManager
*/
module.exports = {
/**
* @constant {string} DOWNLOADS_FOLDER Absolute path to the folder for downloaded files
*/
DOWNLOADS_FOLDER: DOWNLOADS_FOLDER,
/**
* Downloads a file from a Slack file object. File name may change if a file by the same name already exists according to {@link getValidFileName}
* @async
* @param {Object} fileObj Slack file details object (Found in event.files[])
* @param {string} [fileName] Name for file (Defaults to the name provided by Slack)
* @param {string} [auth] Alternative token to use (in place of the environment variables)
* @returns {Promise<FileData>} An object containing details on where the file is, what it is called, the original Slack file object, and more
*/
fileDownload: async(fileObj, fileName, auth) => {
fileName = (fileName || fileObj.name).replace(/\//g, " - ");
let split = fileName.split(".");
let fileFormat = { extension: fileName.includes(".") ? split.pop() : "", name: split.join(".") };
fileName = await getValidFileName(DOWNLOADS_FOLDER, fileFormat.name, fileFormat.extension);
let finalDownloadPath = path.resolve(DOWNLOADS_FOLDER, fileName);
pendingDownloads.push(finalDownloadPath);
try {
await completeDownload(finalDownloadPath, fileObj["url_private_download"], {
Authorization: `Bearer ${auth || DEFAULT_TOKEN}`
}, true);
} catch(err) {
console.error(`Failed to Download File. Using Default File as Attachment. Reason: ${err}`);
finalDownloadPath = FAILED_DOWNLOAD_IMAGE_PATH;
}
// fileObj.size has the size in bytes too but it isn't as accurate
const downloadSize = await fileSize(finalDownloadPath);
return {
name: fileObj.name,
title: fileObj.title,
path: finalDownloadPath,
storedAs: fileName,
extension: fileFormat.extension,
size: downloadSize,
original: fileObj,
id: fileObj.id
};
},
fileDelete,
/**
* Absolute path of a default image to send when the download fails
* @type {string}
*/
FAILED_DOWNLOAD_IMAGE_PATH
}
/**
* Checks if a specified file name is available in a given folder path. If not, a number in parentheses will be appended to it
* If 'image.png' does not already exist, inputting 'image.png' into this function will return 'image.png'
* If 'image.png' already exists, inputting 'image.png' into this function will return 'image (1).png' instead
* @async
* @param {string} rootPath The location of the folder to check (Absolute Path Only)
* @param {string} fileName Name to give the file
* @param {string} fileExtension File extension
* @returns {Promise<string>} Returns a file name that isn't already being used in the folder
*/
async function getValidFileName(rootPath, fileName, fileExtension) {
let testFileName = `${fileName}.${fileExtension}`;
for(let copyCount = 1; copyCount <= FILE_NAME_ITERATOR_LIMIT; copyCount++) {
let testPath = path.resolve(rootPath, testFileName);
try {
// console.log(`Testing ${testPath}`);
await fs.promises.access(testPath, fs.constants.F_OK);
// console.log(`File: ${testPath} already exists.\nAppending number to path and trying again`);
} catch(err) {
if(!pendingDownloads.includes(testPath)) {
if(err.code === "ENOENT") {
return testFileName;
} else {
console.warn("Unknown error while looking for a path to store download: ", err);
}
}
}
testFileName = `${fileName} (${copyCount}).${fileExtension}`;
}
throw `Could not download file after ${FILE_NAME_ITERATOR_LIMIT} attempts. Rejecting Promise.`;
}
/**
* Downloads a file from a given URL and save it to a given location
* @async
* @param {string} saveTo File save location (Absolute Path Only)
* @param {string} downloadFromURL The URL to download from
* @param {Object} [headers = {}] Optional http request headers
* @param {boolean} [rejectOnRedirect = false] Reject promise on redirects instead of following
* @returns {Promise<string>} Returns the path where the file was saved if successful
*/
async function completeDownload(saveTo, downloadFromURL, headers = {}, rejectOnRedirect = false) {
return new Promise((resolve, reject) => {
https.get(downloadFromURL, {
headers: headers
})
.on("response", response => {
// Redirect handling code. Recursively calls the completeDownload function until no longer redirected so an infinite loop is possible
if(response.statusCode >= 300 && response.statusCode < 400) {
const redirectURL = response.headers.location.startsWith("/") ? `${response.req.protocol}//${response.req.host}${response.headers.location}` : response.headers.location;
if(rejectOnRedirect) {
return reject(new Error(`[HTTP ${response.statusCode}] Redirect Returned from [${downloadFromURL}] to [${redirectURL}]`));
}
console.warn(`[HTTP ${response.statusCode}] Following Redirect to File at [${redirectURL}]\nNote that if this happens and fails a lot, the token may be invalid`);
return resolve(completeDownload(saveTo, `${redirectURL}`, headers));
}
if(response.statusCode !== 200) {
console.warn(`[HTTP ${response.statusCode}] [${response.statusMessage}] from [${downloadFromURL}]\nā ā ā Request Returned a Non-200 Status Code. Proceeding Anyways...`);
}
console.log(`Saving a File to ${saveTo} from ${downloadFromURL}`);
const saveFile = fs.createWriteStream(saveTo);
saveFile
.on('finish', () => {
pendingDownloads.splice(pendingDownloads.indexOf(saveTo), 1);
resolve(saveTo);
}).on("error", err => completeDownloadErrorHandler(err, saveTo));
response
.pipe(saveFile)
.on("error", err => {
console.warn(`Unable to Pipe File Contents into File: ${err}`);
saveFile.end();
reject(completeDownloadErrorHandler(err, saveTo));
});
}).on("error",
err => completeDownloadErrorHandler(err)
);
});
}
/**
* Error handler for completeDownload. Tries to delete file on a failed download
* @async
* @param {Error} err Error from completeDownload
* @param {string} [unlinkLocation] Path of intended file to unlink
* @return {Promise} Throws errors through the Promise
*/
async function completeDownloadErrorHandler(err, unlinkLocation) {
// Blindly deletes the file asynchronously on error
if(unlinkLocation) {
await fileDelete(unlinkLocation)
.catch(unlinkErr => {
throw new Error(`Download Failed and Unlink Failed: ${unlinkErr}`);
});
}
throw new Error(`Download Failed: ${err}`);
}
/**
* Returns the size of a file in megabytes
* @async
* @param {string} filePath Absolute path to file to check the size of
* @returns {Promise<number>} Size in megabytes (Includes decimals)
*/
async function fileSize(filePath) {
return (await fs.promises.stat(filePath)).size / (1024 * 1024);
}
/**
* Deletes a file from the downloads folder specifically
* @param {string} fileName Name of file to delete from the downloads folder
* @returns {Promise} Returns the promise from fs.promises.unlink
*/
function fileDelete(fileName) {
let fileDeletePath = path.resolve(DOWNLOADS_FOLDER, fileName);
// console.log(`Del: ${fileDeletePath}`);
return process.env.DISABLE_FILE_DELETION?.trim().toLowerCase() === "true" ? Promise.resolve() : fs.promises.unlink(fileDeletePath);
}