-
Notifications
You must be signed in to change notification settings - Fork 2
/
fileManager.js
203 lines (187 loc) · 8.4 KB
/
fileManager.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
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);
}