-
-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathuploads.controller.js
More file actions
200 lines (187 loc) · 8.09 KB
/
uploads.controller.js
File metadata and controls
200 lines (187 loc) · 8.09 KB
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
/**
* Module dependencies
*/
import sharp from 'sharp';
import _ from 'lodash';
import config from '../../../config/index.js';
import errors from '../../../lib/helpers/errors.js';
import logger from '../../../lib/services/logger.js';
import responses from '../../../lib/helpers/responses.js';
import UploadsService from '../services/uploads.service.js';
/**
* Allowlisted MIME types for private download (get).
* Defense-in-depth: prevents stored-XSS when a downstream kind permits a dangerous MIME.
*/
const SAFE_MIME = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']);
/**
* Allowlisted MIME types for public image serving (getSharp).
* Restricted to image types — the sharp pipeline only handles images.
*/
const SAFE_IMAGE_MIME = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']);
/**
* Normalize a raw Content-Type value for safe allowlist comparison.
* MIME types are case-insensitive and may include parameters (e.g. `image/jpeg; charset=binary`).
* Strip any `;` parameter segment, lowercase, and trim before checking the allowlist.
* @param {string} raw - Raw content-type string.
* @returns {string} Normalized MIME type (e.g. `image/jpeg`).
*/
const normalizeMime = (raw) => String(raw).toLowerCase().split(';')[0].trim();
/**
* @desc Endpoint to get an upload by fileName
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const get = async (req, res) => {
try {
const stream = await UploadsService.getStream({ _id: req.upload._id });
if (!stream) return responses.error(res, 404, 'Not Found', 'No Upload with that identifier can been found')();
stream.on('error', (err) => {
// Guard against ERR_HTTP_HEADERS_SENT when GridFS fails mid-transfer.
// Response headers are written when the first chunk is piped to res
// (`stream.pipe(res)` below). If GridFS errors after that point, a
// second status+body write would throw ERR_HTTP_HEADERS_SENT and crash
// the worker process. Pre-header errors still reach the client as 422;
// post-header errors destroy the socket so Express surfaces them via
// its default error handler instead of double-sending.
if (!res.headersSent) {
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
} else {
logger.error('uploads.get - stream error after headers sent', err);
res.destroy(err);
}
});
const raw = req.upload.contentType || req.upload.metadata?.contentType || 'application/octet-stream';
const norm = normalizeMime(raw);
const contentType = SAFE_MIME.has(norm) ? norm : 'application/octet-stream';
res.set('Content-Type', contentType);
res.set('Content-Disposition', 'attachment');
if (req.upload.length) res.set('Content-Length', req.upload.length);
stream.pipe(res);
} catch (err) {
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
}
};
/**
* @desc Endpoint to get an upload by fileName with sharp options
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @returns {Promise<void>} Resolves when the image stream has been piped to the response.
*/
const getSharp = async (req, res) => {
try {
const stream = await UploadsService.getStream({ _id: req.upload._id });
if (!stream) return responses.error(res, 404, 'Not Found', 'No Upload with that identifier can been found')();
// headersSent guard for the GridFS source stream.
// pipe() does not forward 'error' events between streams; each stream in the
// chain needs its own listener. Source and Transform errors are handled below.
// Pre-header errors reach the client as 422; post-header errors destroy the
// socket so Express surfaces them via its default handler instead of
// attempting a second write that would throw ERR_HTTP_HEADERS_SENT.
const onStreamError = (label) => (err) => {
if (!res.headersSent) {
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
} else {
logger.error(`uploads.getSharp - ${label} error after headers sent`, err);
res.destroy(err);
}
};
stream.on('error', onStreamError('source stream'));
const raw = req.upload.contentType || req.upload.metadata?.contentType || 'application/octet-stream';
const norm = normalizeMime(raw);
const contentType = SAFE_IMAGE_MIME.has(norm) ? norm : 'image/jpeg';
res.set('Content-Type', contentType);
const buildTransform = () => {
let transform;
switch (req.sharpOption) {
case 'blur':
transform = sharp().resize(req.sharpSize).blur(config.uploads.sharp.blur);
break;
case 'bw':
transform = sharp().resize(req.sharpSize).grayscale();
break;
case 'blur&bw':
transform = sharp().resize(req.sharpSize).grayscale().blur(config.uploads.sharp.blur);
break;
default:
transform = sharp().resize(req.sharpSize);
}
transform.on('error', onStreamError('sharp transform'));
return transform;
};
stream.pipe(buildTransform()).pipe(res);
} catch (err) {
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
}
};
/**
* @desc Endpoint to remove an upload
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const remove = async (req, res) => {
try {
await UploadsService.remove({ _id: req.upload._id });
responses.success(res, 'upload deleted')();
} catch (err) {
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
}
};
/**
* @desc MiddleWare to ask the service the uppload for this uploadName
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
* @param {String} filename - upload filename
*/
const uploadByName = async (req, res, next, uploadName) => {
try {
const upload = await UploadsService.get(uploadName);
if (!upload) responses.error(res, 404, 'Not Found', 'No Upload with that name has been found')();
else {
req.upload = upload;
next();
}
} catch (err) {
next(err);
}
};
/**
* @desc MiddleWare to ask the service the uppload for this uploadImageName
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
* @param {String} filename & params - upload filename & eventual params (two max) filename-maxSize-options.png
*/
const uploadByImageName = async (req, res, next, uploadImageName) => {
try {
// Name
const imageName = uploadImageName.split('.');
const opts = imageName[0].split('-');
if (imageName.length !== 2) return responses.error(res, 404, 'Not Found', 'Wrong name schema')();
if (opts.length > 3) return responses.error(res, 404, 'Not Found', 'Too much params')();
// data work
const upload = await UploadsService.get(`${opts[0]}.${imageName[1]}`);
if (!upload) return responses.error(res, 404, 'Not Found', 'No Upload with that name has been found')();
// options
const sharp = _.get(config, `uploads.${upload.metadata.kind}.sharp`);
if (opts[1] && (!sharp || !sharp.sizes)) return responses.error(res, 422, 'Unprocessable Entity', 'Size param not available')();
if (opts[1] && (!/^\d+$/.test(opts[1]) || !sharp.sizes.includes(opts[1])))
return responses.error(res, 422, 'Unprocessable Entity', 'Wrong size param')();
if (opts[2] && (!sharp || !sharp.operations)) return responses.error(res, 422, 'Unprocessable Entity', 'Operations param not available')();
if (opts[2] && !sharp.operations.includes(opts[2])) return responses.error(res, 422, 'Unprocessable Entity', 'Operation param not available')();
// return
req.upload = upload;
req.sharpSize = parseInt(opts[1], 10) || null;
req.sharpOption = opts[2] || null;
next();
} catch (err) {
next(err);
}
};
export default {
get,
getSharp,
remove,
uploadByName,
uploadByImageName,
};