@reldens/server-utils 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/app-server-factory.js +100 -15
- package/lib/file-handler.js +182 -11
- package/lib/uploader-factory.js +153 -22
- package/package.json +4 -2
|
@@ -12,6 +12,8 @@ const bodyParser = require('body-parser');
|
|
|
12
12
|
const session = require('express-session');
|
|
13
13
|
const rateLimit = require('express-rate-limit');
|
|
14
14
|
const cors = require('cors');
|
|
15
|
+
const helmet = require('helmet');
|
|
16
|
+
const xss = require('xss-clean');
|
|
15
17
|
|
|
16
18
|
class AppServerFactory
|
|
17
19
|
{
|
|
@@ -23,6 +25,7 @@ class AppServerFactory
|
|
|
23
25
|
this.session = session;
|
|
24
26
|
this.appServer = false;
|
|
25
27
|
this.app = express();
|
|
28
|
+
this.rateLimit = rateLimit;
|
|
26
29
|
this.useCors = 1 === Number(process.env.RELDENS_USE_CORS || 1);
|
|
27
30
|
this.useExpressJson = 1 === Number(process.env.RELDENS_USE_EXPRESS_JSON || 1);
|
|
28
31
|
this.useUrlencoded = 1 === Number(process.env.RELDENS_USE_URLENCODED || 1);
|
|
@@ -36,6 +39,14 @@ class AppServerFactory
|
|
|
36
39
|
this.windowMs = Number(process.env.RELDENS_EXPRESS_RATE_LIMIT_MS || 60000);
|
|
37
40
|
this.maxRequests = Number(process.env.RELDENS_EXPRESS_RATE_LIMIT_MAX_REQUESTS || 30);
|
|
38
41
|
this.applyKeyGenerator = 1 === Number(process.env.RELDENS_EXPRESS_RATE_LIMIT_APPLY_KEY_GENERATOR || 0);
|
|
42
|
+
this.jsonLimit = String(process.env.RELDENS_EXPRESS_JSON_LIMIT || '1mb');
|
|
43
|
+
this.urlencodedLimit = String(process.env.RELDENS_EXPRESS_URLENCODED_LIMIT || '1mb');
|
|
44
|
+
this.useHelmet = 1 === Number(process.env.RELDENS_USE_HELMET || 1);
|
|
45
|
+
this.useXssProtection = 1 === Number(process.env.RELDENS_USE_XSS_PROTECTION || 1);
|
|
46
|
+
this.globalRateLimit = 1 === Number(process.env.RELDENS_GLOBAL_RATE_LIMIT || 0);
|
|
47
|
+
this.corsOrigin = String(process.env.RELDENS_CORS_ORIGIN || '*');
|
|
48
|
+
this.corsMethods = String(process.env.RELDENS_CORS_METHODS || 'GET,POST').split(',');
|
|
49
|
+
this.corsHeaders = String(process.env.RELDENS_CORS_HEADERS || 'Content-Type,Authorization').split(',');
|
|
39
50
|
this.errorMessage = '';
|
|
40
51
|
}
|
|
41
52
|
|
|
@@ -44,14 +55,46 @@ class AppServerFactory
|
|
|
44
55
|
if(appServerConfig){
|
|
45
56
|
Object.assign(this, appServerConfig);
|
|
46
57
|
}
|
|
58
|
+
if(this.useHelmet){
|
|
59
|
+
this.app.use(helmet());
|
|
60
|
+
}
|
|
47
61
|
if(this.useCors){
|
|
48
|
-
|
|
62
|
+
let corsOptions = {
|
|
63
|
+
origin: this.corsOrigin,
|
|
64
|
+
methods: this.corsMethods,
|
|
65
|
+
allowedHeaders: this.corsHeaders
|
|
66
|
+
};
|
|
67
|
+
this.app.use(cors(corsOptions));
|
|
68
|
+
}
|
|
69
|
+
if(this.globalRateLimit){
|
|
70
|
+
let limiterParams = {
|
|
71
|
+
windowMs: this.windowMs,
|
|
72
|
+
max: this.maxRequests,
|
|
73
|
+
standardHeaders: true,
|
|
74
|
+
legacyHeaders: false,
|
|
75
|
+
message: 'Too many requests from this IP, please try again later'
|
|
76
|
+
};
|
|
77
|
+
if(this.applyKeyGenerator){
|
|
78
|
+
limiterParams.keyGenerator = function(req){
|
|
79
|
+
return req.ip;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
this.app.use(this.rateLimit(limiterParams));
|
|
83
|
+
}
|
|
84
|
+
if(this.useXssProtection){
|
|
85
|
+
this.app.use(xss());
|
|
49
86
|
}
|
|
50
87
|
if(this.useExpressJson){
|
|
51
|
-
this.app.use(this.applicationFramework.json(
|
|
88
|
+
this.app.use(this.applicationFramework.json({
|
|
89
|
+
limit: this.jsonLimit,
|
|
90
|
+
verify: this.verifyContentTypeJson
|
|
91
|
+
}));
|
|
52
92
|
}
|
|
53
93
|
if(this.useUrlencoded){
|
|
54
|
-
this.app.use(this.bodyParser.urlencoded({
|
|
94
|
+
this.app.use(this.bodyParser.urlencoded({
|
|
95
|
+
extended: true,
|
|
96
|
+
limit: this.urlencodedLimit
|
|
97
|
+
}));
|
|
55
98
|
}
|
|
56
99
|
if('' !== this.trustedProxy){
|
|
57
100
|
this.app.enable('trust proxy', this.trustedProxy);
|
|
@@ -60,19 +103,30 @@ class AppServerFactory
|
|
|
60
103
|
return {app: this.app, appServer: this.appServer};
|
|
61
104
|
}
|
|
62
105
|
|
|
106
|
+
verifyContentTypeJson(req, res, buf)
|
|
107
|
+
{
|
|
108
|
+
let contentType = req.headers['content-type'] || '';
|
|
109
|
+
if(
|
|
110
|
+
req.method === 'POST'
|
|
111
|
+
&& 0 < buf.length
|
|
112
|
+
&& !contentType.includes('application/json')
|
|
113
|
+
&& !contentType.includes('multipart/form-data')
|
|
114
|
+
){
|
|
115
|
+
throw new Error('Invalid content-type');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
63
119
|
createServer()
|
|
64
120
|
{
|
|
65
121
|
if(!this.useHttps){
|
|
66
122
|
return http.createServer(this.app);
|
|
67
123
|
}
|
|
68
|
-
let key = FileHandler.readFile(this.keyPath);
|
|
124
|
+
let key = FileHandler.readFile(this.keyPath, 'Key');
|
|
69
125
|
if(!key){
|
|
70
|
-
this.errorMessage = 'Key file not found: ' + this.keyPath;
|
|
71
126
|
return false;
|
|
72
127
|
}
|
|
73
|
-
let cert = FileHandler.readFile(this.certPath);
|
|
128
|
+
let cert = FileHandler.readFile(this.certPath, 'Cert');
|
|
74
129
|
if(!cert){
|
|
75
|
-
this.errorMessage = 'Cert file not found: ' + this.certPath;
|
|
76
130
|
return false;
|
|
77
131
|
}
|
|
78
132
|
let credentials = {
|
|
@@ -81,7 +135,7 @@ class AppServerFactory
|
|
|
81
135
|
passphrase: this.passphrase
|
|
82
136
|
};
|
|
83
137
|
if('' !== this.httpsChain){
|
|
84
|
-
let ca = FileHandler.readFile(this.httpsChain);
|
|
138
|
+
let ca = FileHandler.readFile(this.httpsChain, 'Certificate Authority');
|
|
85
139
|
if(ca){
|
|
86
140
|
credentials.ca = ca;
|
|
87
141
|
}
|
|
@@ -92,17 +146,17 @@ class AppServerFactory
|
|
|
92
146
|
async enableServeHome(app, homePageLoadCallback)
|
|
93
147
|
{
|
|
94
148
|
let limiterParams = {
|
|
95
|
-
// default 60000 = 1 minute:
|
|
96
149
|
windowMs: this.windowMs,
|
|
97
|
-
// limit each IP to 30 requests per windowMs:
|
|
98
150
|
max: this.maxRequests,
|
|
151
|
+
standardHeaders: true,
|
|
152
|
+
legacyHeaders: false
|
|
99
153
|
};
|
|
100
154
|
if(this.applyKeyGenerator){
|
|
101
|
-
limiterParams.keyGenerator = function
|
|
155
|
+
limiterParams.keyGenerator = function(req){
|
|
102
156
|
return req.ip;
|
|
103
157
|
};
|
|
104
158
|
}
|
|
105
|
-
let limiter = rateLimit(limiterParams);
|
|
159
|
+
let limiter = this.rateLimit(limiterParams);
|
|
106
160
|
app.post('/', limiter);
|
|
107
161
|
app.post('/', async (req, res, next) => {
|
|
108
162
|
if('/' === req._parsedUrl.pathname){
|
|
@@ -116,7 +170,12 @@ class AppServerFactory
|
|
|
116
170
|
if('function' !== typeof homePageLoadCallback){
|
|
117
171
|
return res.send('Homepage contents could not be loaded.');
|
|
118
172
|
}
|
|
119
|
-
|
|
173
|
+
try {
|
|
174
|
+
return res.send(await homePageLoadCallback(req));
|
|
175
|
+
} catch(error){
|
|
176
|
+
this.errorMessage = 'Error loading homepage.';
|
|
177
|
+
return res.status(500).send('Error loading homepage.');
|
|
178
|
+
}
|
|
120
179
|
}
|
|
121
180
|
next();
|
|
122
181
|
});
|
|
@@ -124,12 +183,38 @@ class AppServerFactory
|
|
|
124
183
|
|
|
125
184
|
async serveStatics(app, statics)
|
|
126
185
|
{
|
|
127
|
-
|
|
186
|
+
if(!FileHandler.isValidPath(statics)){
|
|
187
|
+
this.errorMessage = 'Invalid statics path.';
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
let staticOptions = {
|
|
191
|
+
maxAge: '1d',
|
|
192
|
+
etag: true,
|
|
193
|
+
lastModified: true,
|
|
194
|
+
index: false,
|
|
195
|
+
setHeaders: function(res){
|
|
196
|
+
res.set('X-Content-Type-Options', 'nosniff');
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
app.use(this.applicationFramework.static(statics, staticOptions));
|
|
128
200
|
}
|
|
129
201
|
|
|
130
202
|
async serveStaticsPath(app, staticsPath, statics)
|
|
131
203
|
{
|
|
132
|
-
|
|
204
|
+
if(!FileHandler.isValidPath(staticsPath) || !FileHandler.isValidPath(statics)){
|
|
205
|
+
this.errorMessage = 'Invalid statics path to be served.';
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
let staticOptions = {
|
|
209
|
+
maxAge: '1d',
|
|
210
|
+
etag: true,
|
|
211
|
+
lastModified: true,
|
|
212
|
+
index: false,
|
|
213
|
+
setHeaders: function(res){
|
|
214
|
+
res.set('X-Content-Type-Options', 'nosniff');
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
app.use(staticsPath, this.applicationFramework.static(statics, staticOptions));
|
|
133
218
|
}
|
|
134
219
|
|
|
135
220
|
}
|
package/lib/file-handler.js
CHANGED
|
@@ -24,13 +24,57 @@ class FileHandler
|
|
|
24
24
|
|
|
25
25
|
exists(fullPath)
|
|
26
26
|
{
|
|
27
|
+
if(!this.isValidPath(fullPath)){
|
|
28
|
+
this.error = {message: 'Invalid path.', fullPath};
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
27
31
|
return fs.existsSync(fullPath);
|
|
28
32
|
}
|
|
29
33
|
|
|
34
|
+
isValidPath(filePath)
|
|
35
|
+
{
|
|
36
|
+
if(!filePath){
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
let pathStr = String(filePath);
|
|
40
|
+
if(pathStr.includes('../') || pathStr.includes('..\\')){
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
sanitizePath(filePath)
|
|
47
|
+
{
|
|
48
|
+
if(!filePath){
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
let sanitized = String(filePath)
|
|
52
|
+
.replace(/\.\./g, '')
|
|
53
|
+
.replace(/[:*?"<>|]/g, '')
|
|
54
|
+
.substring(0, 255);
|
|
55
|
+
return sanitized;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
generateSecureFilename(originalName)
|
|
59
|
+
{
|
|
60
|
+
let ext = path.extname(originalName).toLowerCase();
|
|
61
|
+
let randomStr = '';
|
|
62
|
+
let chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
63
|
+
let charLength = chars.length;
|
|
64
|
+
for(let i = 0; i < 32; i++){
|
|
65
|
+
randomStr += chars.charAt(Math.floor(Math.random() * charLength));
|
|
66
|
+
}
|
|
67
|
+
return randomStr + ext;
|
|
68
|
+
}
|
|
69
|
+
|
|
30
70
|
remove(fullPath)
|
|
31
71
|
{
|
|
32
72
|
try {
|
|
33
|
-
|
|
73
|
+
if(!this.isValidPath(fullPath)){
|
|
74
|
+
this.error = {message: 'Invalid path for removal.', fullPath};
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
let deletePath = Array.isArray(fullPath) ? this.joinPaths(...fullPath) : fullPath;
|
|
34
78
|
if(fs.existsSync(deletePath)){
|
|
35
79
|
fs.rmSync(deletePath, {recursive: true, force: true});
|
|
36
80
|
return true;
|
|
@@ -44,6 +88,10 @@ class FileHandler
|
|
|
44
88
|
createFolder(folderPath)
|
|
45
89
|
{
|
|
46
90
|
try {
|
|
91
|
+
if(!this.isValidPath(folderPath)){
|
|
92
|
+
this.error = {message: 'Invalid folder path.', folderPath};
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
47
95
|
if(fs.existsSync(folderPath)){
|
|
48
96
|
return true;
|
|
49
97
|
}
|
|
@@ -58,6 +106,10 @@ class FileHandler
|
|
|
58
106
|
copyFolderSync(from, to)
|
|
59
107
|
{
|
|
60
108
|
try {
|
|
109
|
+
if(!this.isValidPath(from) || !this.isValidPath(to)){
|
|
110
|
+
this.error = {message: 'Invalid path for folder copy.', from, to};
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
61
113
|
fs.mkdirSync(to, {recursive: true});
|
|
62
114
|
let folders = fs.readdirSync(from);
|
|
63
115
|
for(let element of folders){
|
|
@@ -80,6 +132,10 @@ class FileHandler
|
|
|
80
132
|
|
|
81
133
|
copyFileSyncIfDoesNotExist(from, to)
|
|
82
134
|
{
|
|
135
|
+
if(!this.isValidPath(from) || !this.isValidPath(to)){
|
|
136
|
+
this.error = {message: 'Invalid path for file copy.', from, to};
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
83
139
|
if(!fs.existsSync(to)){
|
|
84
140
|
return fs.copyFileSync(from, to);
|
|
85
141
|
}
|
|
@@ -87,6 +143,10 @@ class FileHandler
|
|
|
87
143
|
|
|
88
144
|
copyFile(from, to)
|
|
89
145
|
{
|
|
146
|
+
if(!this.isValidPath(from) || !this.isValidPath(to)){
|
|
147
|
+
this.error = {message: 'Invalid path for file copy.', from, to};
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
90
150
|
let origin = Array.isArray(from) ? this.joinPaths(...from) : from;
|
|
91
151
|
let dest = Array.isArray(to) ? this.joinPaths(...to) : to;
|
|
92
152
|
if(!this.exists(origin)){
|
|
@@ -109,11 +169,19 @@ class FileHandler
|
|
|
109
169
|
|
|
110
170
|
readFolder(folder, options)
|
|
111
171
|
{
|
|
172
|
+
if(!this.isValidPath(folder)){
|
|
173
|
+
this.error = {message: 'Invalid folder path.', folder};
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
112
176
|
return fs.readdirSync(folder, options);
|
|
113
177
|
}
|
|
114
178
|
|
|
115
179
|
fetchSubFoldersList(folder, options)
|
|
116
180
|
{
|
|
181
|
+
if(!this.isValidPath(folder)){
|
|
182
|
+
this.error = {message: 'Invalid folder path.', folder};
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
117
185
|
let files = fs.readdirSync(folder, options);
|
|
118
186
|
let subFolders = [];
|
|
119
187
|
for(let file of files){
|
|
@@ -127,6 +195,10 @@ class FileHandler
|
|
|
127
195
|
|
|
128
196
|
isFile(filePath)
|
|
129
197
|
{
|
|
198
|
+
if(!this.isValidPath(filePath)){
|
|
199
|
+
this.error = {message: 'Invalid file path.', filePath};
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
130
202
|
try {
|
|
131
203
|
return fs.lstatSync(filePath).isFile();
|
|
132
204
|
} catch (error) {
|
|
@@ -137,6 +209,10 @@ class FileHandler
|
|
|
137
209
|
|
|
138
210
|
permissionsCheck(systemPath)
|
|
139
211
|
{
|
|
212
|
+
if(!this.isValidPath(systemPath)){
|
|
213
|
+
this.error = {message: 'Invalid system path.', systemPath};
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
140
216
|
try {
|
|
141
217
|
let crudTestPath = path.join(systemPath, 'crud-test');
|
|
142
218
|
fs.mkdirSync(crudTestPath, {recursive: true});
|
|
@@ -150,49 +226,84 @@ class FileHandler
|
|
|
150
226
|
|
|
151
227
|
fetchFileJson(filePath)
|
|
152
228
|
{
|
|
229
|
+
if(!this.isValidPath(filePath)){
|
|
230
|
+
this.error = {message: 'Invalid file path.', filePath};
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
153
233
|
let fileContents = this.fetchFileContents(filePath);
|
|
154
234
|
if(!fileContents){
|
|
155
235
|
this.error = {message: 'Failed to fetch file contents.', filePath};
|
|
156
236
|
return false;
|
|
157
237
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
238
|
+
try {
|
|
239
|
+
return JSON.parse(fileContents);
|
|
240
|
+
} catch(error){
|
|
241
|
+
this.error = {message: 'Can not parse data file.', filePath, error};
|
|
161
242
|
return false;
|
|
162
243
|
}
|
|
163
|
-
return importedJson;
|
|
164
244
|
}
|
|
165
245
|
|
|
166
246
|
fetchFileContents(filePath)
|
|
167
247
|
{
|
|
248
|
+
if(!this.isValidPath(filePath)){
|
|
249
|
+
this.error = {message: 'Invalid file path.', filePath};
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
168
252
|
if(!this.isFile(filePath)){
|
|
169
253
|
this.error = {message: 'File check failed to fetch file contents.', filePath};
|
|
170
254
|
return false;
|
|
171
255
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
256
|
+
try {
|
|
257
|
+
let fileContent = this.readFile(filePath);
|
|
258
|
+
if(!fileContent){
|
|
259
|
+
this.error = {message: 'Can not read data or empty file.', filePath};
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
return fileContent;
|
|
263
|
+
} catch(error){
|
|
264
|
+
this.error = {message: 'Error reading file.', filePath, error};
|
|
175
265
|
return false;
|
|
176
266
|
}
|
|
177
|
-
return fileContent;
|
|
178
267
|
}
|
|
179
268
|
|
|
180
269
|
readFile(filePath)
|
|
181
270
|
{
|
|
271
|
+
if(!this.isValidPath(filePath)){
|
|
272
|
+
this.error = {message: 'Invalid file path.', filePath};
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
182
275
|
if(!filePath){
|
|
183
276
|
this.error = {message: 'Missing data file.', filePath};
|
|
184
277
|
return false;
|
|
185
278
|
}
|
|
186
|
-
|
|
279
|
+
try {
|
|
280
|
+
return fs.readFileSync(filePath, {encoding: this.encoding, flag: 'r'});
|
|
281
|
+
} catch(error){
|
|
282
|
+
this.error = {message: 'Error reading file.', filePath, error};
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
187
285
|
}
|
|
188
286
|
|
|
189
287
|
async updateFileContents(filePath, contents)
|
|
190
288
|
{
|
|
191
|
-
|
|
289
|
+
if(!this.isValidPath(filePath)){
|
|
290
|
+
this.error = {message: 'Invalid file path.', filePath};
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
return fs.writeFileSync(fs.openSync(filePath, 'w+'), contents);
|
|
295
|
+
} catch(error){
|
|
296
|
+
this.error = {message: 'Error updating file.', filePath, error};
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
192
299
|
}
|
|
193
300
|
|
|
194
301
|
writeFile(fileName, content)
|
|
195
302
|
{
|
|
303
|
+
if(!this.isValidPath(fileName)){
|
|
304
|
+
this.error = {message: 'Invalid file name.', fileName};
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
196
307
|
try {
|
|
197
308
|
fs.writeFileSync(fileName, content, this.encoding);
|
|
198
309
|
return true;
|
|
@@ -202,6 +313,66 @@ class FileHandler
|
|
|
202
313
|
return false;
|
|
203
314
|
}
|
|
204
315
|
|
|
316
|
+
validateFileType(filePath, allowedType, allowedFileTypes, maxFileSize)
|
|
317
|
+
{
|
|
318
|
+
if(!this.isFile(filePath)){
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
let extension = path.extname(filePath).toLowerCase();
|
|
322
|
+
let allowedExtensions = allowedFileTypes[allowedType] || allowedFileTypes.any;
|
|
323
|
+
if(0 === allowedExtensions.length){
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
if(!allowedExtensions.includes(extension)){
|
|
327
|
+
this.error = {message: 'Invalid file extension.', extension, allowedType};
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
let fileSize = fs.statSync(filePath).size;
|
|
331
|
+
if(fileSize > maxFileSize){
|
|
332
|
+
this.error = {message: 'File too large.', fileSize, maxFileSize};
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
isValidJson(filePath)
|
|
339
|
+
{
|
|
340
|
+
if(!this.isFile(filePath)){
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
JSON.parse(this.readFile(filePath));
|
|
345
|
+
return true;
|
|
346
|
+
} catch(error){
|
|
347
|
+
this.error = {message: 'Invalid JSON file.', filePath, error};
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
getFirstFileBytes(filePath, bytes = 4100)
|
|
353
|
+
{
|
|
354
|
+
if(!this.isFile(filePath)){
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
let fd;
|
|
358
|
+
try {
|
|
359
|
+
fd = fs.openSync(filePath, 'r');
|
|
360
|
+
let buffer = Buffer.alloc(bytes);
|
|
361
|
+
let bytesRead = fs.readSync(fd, buffer, 0, bytes, 0);
|
|
362
|
+
fs.closeSync(fd);
|
|
363
|
+
return buffer.slice(0, bytesRead);
|
|
364
|
+
} catch(err){
|
|
365
|
+
if(fd !== undefined){
|
|
366
|
+
try {
|
|
367
|
+
fs.closeSync(fd);
|
|
368
|
+
} catch(e){
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
this.error = {message: 'Error reading file head.', filePath, error: err};
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
205
376
|
}
|
|
206
377
|
|
|
207
378
|
module.exports.FileHandler = new FileHandler();
|
package/lib/uploader-factory.js
CHANGED
|
@@ -12,45 +12,157 @@ class UploaderFactory
|
|
|
12
12
|
|
|
13
13
|
constructor(props)
|
|
14
14
|
{
|
|
15
|
-
this.mimeTypes = props.mimeTypes;
|
|
15
|
+
this.mimeTypes = props.mimeTypes || {};
|
|
16
16
|
this.error = {message: ''};
|
|
17
|
+
this.maxFileSize = props.maxFileSize || 20 * 1024 * 1024;
|
|
18
|
+
this.fileLimit = props.fileLimit || 0;
|
|
19
|
+
this.allowedExtensions = props.allowedExtensions;
|
|
20
|
+
this.applySecureFileNames = props.applySecureFileNames;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
createUploader(fields, buckets, allowedFileTypes)
|
|
20
24
|
{
|
|
21
|
-
|
|
25
|
+
if(!this.validateInputs(fields, buckets, allowedFileTypes)){
|
|
26
|
+
throw new Error('Invalid uploader configuration: ' + this.error.message);
|
|
27
|
+
}
|
|
28
|
+
let diskStorageConfiguration = {
|
|
22
29
|
destination: (req, file, cb) => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
let dest = buckets[file.fieldname];
|
|
31
|
+
if(!FileHandler.isValidPath(dest)){
|
|
32
|
+
return cb(new Error('Invalid destination path'));
|
|
33
|
+
}
|
|
34
|
+
FileHandler.createFolder(dest);
|
|
35
|
+
cb(null, dest);
|
|
27
36
|
}
|
|
28
|
-
}
|
|
29
|
-
|
|
37
|
+
};
|
|
38
|
+
if(this.applySecureFileNames){
|
|
39
|
+
diskStorageConfiguration['filename'] = (req, file, cb) => {
|
|
40
|
+
let secureFilename = FileHandler.generateSecureFilename(file.originalname);
|
|
41
|
+
if(!req.fileNameMapping){
|
|
42
|
+
req.fileNameMapping = {};
|
|
43
|
+
}
|
|
44
|
+
req.fileNameMapping[secureFilename] = file.originalname;
|
|
45
|
+
cb(null, secureFilename);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
let storage = multer.diskStorage(diskStorageConfiguration);
|
|
49
|
+
let limits = {
|
|
50
|
+
fileSize: this.maxFileSize
|
|
51
|
+
};
|
|
52
|
+
if(0 < this.fileLimit){
|
|
53
|
+
limits['files'] = this.fileLimit;
|
|
54
|
+
}
|
|
55
|
+
let upload = multer({
|
|
30
56
|
storage,
|
|
57
|
+
limits,
|
|
31
58
|
fileFilter: (req, file, cb) => {
|
|
32
|
-
return this.
|
|
59
|
+
return this.validateFile(file, allowedFileTypes[file.fieldname], cb);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return (req, res, next) => {
|
|
63
|
+
upload.fields(fields)(req, res, async (err) => {
|
|
64
|
+
if(err){
|
|
65
|
+
if(err instanceof multer.MulterError){
|
|
66
|
+
if(err.code === 'LIMIT_FILE_SIZE'){
|
|
67
|
+
return res.status(413).send('File too large');
|
|
68
|
+
}
|
|
69
|
+
if(err.code === 'LIMIT_FILE_COUNT'){
|
|
70
|
+
return res.status(413).send('Too many files');
|
|
71
|
+
}
|
|
72
|
+
return res.status(400).send('File upload error: ' + err.message);
|
|
73
|
+
}
|
|
74
|
+
return res.status(500).send('Server error during file upload');
|
|
75
|
+
}
|
|
76
|
+
if(!req.files){
|
|
77
|
+
return next();
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
for(let fieldName in req.files){
|
|
81
|
+
for(let file of req.files[fieldName]){
|
|
82
|
+
if(!await this.validateFileContents(file, allowedFileTypes[fieldName])){
|
|
83
|
+
if(FileHandler.exists(file.path)){
|
|
84
|
+
FileHandler.remove(file.path);
|
|
85
|
+
}
|
|
86
|
+
return res.status(415).send('File contents do not match declared type');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
next();
|
|
91
|
+
} catch(error){
|
|
92
|
+
console.error('File validation error:', error);
|
|
93
|
+
this.cleanupFiles(req.files);
|
|
94
|
+
return res.status(500).send('Error processing uploaded files');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
validateInputs(fields, buckets, allowedFileTypes)
|
|
101
|
+
{
|
|
102
|
+
if(!Array.isArray(fields)){
|
|
103
|
+
this.error = {message: 'Fields must be an array'};
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
if(!buckets || typeof buckets !== 'object'){
|
|
107
|
+
this.error = {message: 'Buckets must be an object'};
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if(!allowedFileTypes || typeof allowedFileTypes !== 'object'){
|
|
111
|
+
this.error = {message: 'AllowedFileTypes must be an object'};
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
for(let field of fields){
|
|
115
|
+
if(!field.name || typeof field.name !== 'string'){
|
|
116
|
+
this.error = {message: 'Field name is invalid'};
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
if(!Object.prototype.hasOwnProperty.call(buckets, field.name)){
|
|
120
|
+
this.error = {message: `Missing bucket for field: ${field.name}`};
|
|
121
|
+
return false;
|
|
33
122
|
}
|
|
34
|
-
|
|
123
|
+
if(!Object.prototype.hasOwnProperty.call(allowedFileTypes, field.name)){
|
|
124
|
+
this.error = {message: `Missing allowedFileType for field: ${field.name}`};
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
35
129
|
}
|
|
36
130
|
|
|
37
|
-
|
|
131
|
+
validateFile(file, allowedFileType, cb)
|
|
38
132
|
{
|
|
39
|
-
if(!
|
|
133
|
+
if(!allowedFileType){
|
|
40
134
|
return cb(null, true);
|
|
41
135
|
}
|
|
42
|
-
let
|
|
43
|
-
|
|
44
|
-
|
|
136
|
+
let fileExtension = FileHandler.extension(file.originalname).toLowerCase();
|
|
137
|
+
let allowedExtensions = this.allowedExtensions[allowedFileType];
|
|
138
|
+
if(allowedExtensions && !allowedExtensions.includes(fileExtension)){
|
|
139
|
+
this.error = {message: `Invalid file extension: ${fileExtension}`};
|
|
45
140
|
return cb(null, false);
|
|
46
141
|
}
|
|
47
|
-
let
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return cb(null,
|
|
142
|
+
let allowedFileTypeRegex = this.convertToRegex(allowedFileType);
|
|
143
|
+
if(!allowedFileTypeRegex){
|
|
144
|
+
this.error = {message: 'File type could not be converted to regex.', allowedFileType};
|
|
145
|
+
return cb(null, false);
|
|
146
|
+
}
|
|
147
|
+
let mimeTypeValid = allowedFileTypeRegex.test(file.mimetype);
|
|
148
|
+
if(!mimeTypeValid){
|
|
149
|
+
this.error = {message: `Invalid MIME type: ${file.mimetype}`};
|
|
150
|
+
return cb(null, false);
|
|
151
|
+
}
|
|
152
|
+
return cb(null, true);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async validateFileContents(file, allowedFileType)
|
|
156
|
+
{
|
|
157
|
+
try {
|
|
158
|
+
if(!FileHandler.isFile(file.path)){
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
return FileHandler.validateFileType(file.path, allowedFileType, this.allowedExtensions, this.maxFileSize);
|
|
162
|
+
} catch(err){
|
|
163
|
+
console.error('Error validating file contents:', err);
|
|
164
|
+
return false;
|
|
51
165
|
}
|
|
52
|
-
this.error = {message: 'File type not supported.', extension, mimeType, allowedFileTypes};
|
|
53
|
-
return cb(null, false);
|
|
54
166
|
}
|
|
55
167
|
|
|
56
168
|
convertToRegex(key)
|
|
@@ -58,10 +170,29 @@ class UploaderFactory
|
|
|
58
170
|
if(!this.mimeTypes[key]){
|
|
59
171
|
return false;
|
|
60
172
|
}
|
|
61
|
-
let types = this.mimeTypes[key].map(type =>
|
|
173
|
+
let types = this.mimeTypes[key].map(type =>
|
|
174
|
+
type.split('/').pop().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
175
|
+
);
|
|
62
176
|
return new RegExp(types.join('|'));
|
|
63
177
|
}
|
|
64
178
|
|
|
179
|
+
cleanupFiles(files)
|
|
180
|
+
{
|
|
181
|
+
if(!files){
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
for(let fieldName in files){
|
|
185
|
+
for(let file of files[fieldName]){
|
|
186
|
+
try {
|
|
187
|
+
if(FileHandler.exists(file.path)){
|
|
188
|
+
FileHandler.remove(file.path);
|
|
189
|
+
}
|
|
190
|
+
} catch(err){
|
|
191
|
+
console.error('Error cleaning up file:', file.path, err);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
65
196
|
}
|
|
66
197
|
|
|
67
198
|
module.exports.UploaderFactory = UploaderFactory;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reldens/server-utils",
|
|
3
3
|
"scope": "@reldens",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"description": "Reldens - Server Utils",
|
|
6
6
|
"author": "Damian A. Pastorini",
|
|
7
7
|
"license": "MIT",
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
"express": "4.21.2",
|
|
41
41
|
"express-rate-limit": "7.5.0",
|
|
42
42
|
"express-session": "1.18.1",
|
|
43
|
-
"
|
|
43
|
+
"helmet": "8.1.0",
|
|
44
|
+
"multer": "^1.4.5-lts.2",
|
|
45
|
+
"xss-clean": "^0.1.4"
|
|
44
46
|
}
|
|
45
47
|
}
|