UNPKG

7.96 kBJavaScriptView Raw
1'use strict';
2
3const Path = require('path');
4
5const Ammo = require('@hapi/ammo');
6const Boom = require('@hapi/boom');
7const Bounce = require('@hapi/bounce');
8const Hoek = require('@hapi/hoek');
9const Validate = require('@hapi/validate');
10
11const Etag = require('./etag');
12const Fs = require('./fs');
13
14
15const internals = {};
16
17
18internals.defaultMap = {
19 gzip: '.gz'
20};
21
22
23internals.schema = Validate.alternatives([
24 Validate.string(),
25 Validate.func(),
26 Validate.object({
27 path: Validate.alternatives(Validate.string(), Validate.func()).required(),
28 confine: Validate.alternatives(Validate.string(), Validate.boolean()).default(true),
29 filename: Validate.string(),
30 mode: Validate.string().valid('attachment', 'inline').allow(false),
31 lookupCompressed: Validate.boolean(),
32 lookupMap: Validate.object().min(1).pattern(/.+/, Validate.string()),
33 etagMethod: Validate.string().valid('hash', 'simple').allow(false),
34 start: Validate.number().integer().min(0).default(0),
35 end: Validate.number().integer().min(Validate.ref('start'))
36 })
37 .with('filename', 'mode')
38]);
39
40
41exports.handler = function (route, options) {
42
43 let settings = Validate.attempt(options, internals.schema, 'Invalid file handler options (' + route.path + ')');
44 settings = (typeof options !== 'object' ? { path: options, confine: '.' } : settings);
45 settings.confine = settings.confine === true ? '.' : settings.confine;
46 Hoek.assert(typeof settings.path !== 'string' || settings.path[settings.path.length - 1] !== '/', 'File path cannot end with a \'/\':', route.path);
47
48 const handler = (request) => {
49
50 const path = (typeof settings.path === 'function' ? settings.path(request) : settings.path);
51 return exports.response(path, settings, request);
52 };
53
54 return handler;
55};
56
57
58exports.load = function (path, request, options) {
59
60 const response = exports.response(path, options, request, true);
61 return internals.prepare(response);
62};
63
64
65exports.response = function (path, options, request, _preloaded) {
66
67 Hoek.assert(!options.mode || ['attachment', 'inline'].indexOf(options.mode) !== -1, 'options.mode must be either false, attachment, or inline');
68
69 if (options.confine) {
70 const confineDir = Path.resolve(request.route.settings.files.relativeTo, options.confine);
71 path = Path.isAbsolute(path) ? Path.normalize(path) : Path.join(confineDir, path);
72
73 // Verify that resolved path is within confineDir
74 if (path.lastIndexOf(confineDir, 0) !== 0) {
75 path = null;
76 }
77 }
78 else {
79 path = Path.isAbsolute(path) ? Path.normalize(path) : Path.join(request.route.settings.files.relativeTo, path);
80 }
81
82 const source = {
83 path,
84 settings: options,
85 stat: null,
86 file: null
87 };
88
89 const prepare = _preloaded ? null : internals.prepare;
90
91 return request.generateResponse(source, { variety: 'file', marshal: internals.marshal, prepare, close: internals.close });
92};
93
94
95internals.prepare = async function (response) {
96
97 const path = response.source.path;
98
99 if (path === null) {
100 throw Boom.forbidden(null, { code: 'EACCES' });
101 }
102
103 const file = response.source.file = new Fs.File(path);
104
105 try {
106 const stat = await file.openStat('r');
107
108 const start = response.source.settings.start || 0;
109 if (response.source.settings.end !== undefined) {
110 response.bytes(response.source.settings.end - start + 1);
111 }
112 else {
113 response.bytes(stat.size - start);
114 }
115
116 if (!response.headers['content-type']) {
117 response.type(response.request.server.mime.path(path).type || 'application/octet-stream');
118 }
119
120 response.header('last-modified', stat.mtime.toUTCString());
121
122 if (response.source.settings.mode) {
123 const fileName = response.source.settings.filename || Path.basename(path);
124 response.header('content-disposition', response.source.settings.mode + '; filename=' + encodeURIComponent(fileName));
125 }
126
127 await Etag.apply(response, stat);
128
129 return response;
130 }
131 catch (err) {
132 internals.close(response);
133 throw err;
134 }
135};
136
137
138internals.marshal = async function (response) {
139
140 if (response.source.settings.lookupCompressed &&
141 !response.source.settings.start &&
142 response.source.settings.end === undefined &&
143 response.request.server.settings.compression !== false) {
144
145 const lookupMap = response.source.settings.lookupMap || internals.defaultMap;
146 const encoding = response.request.info.acceptEncoding;
147 const extension = lookupMap.hasOwnProperty(encoding) ? lookupMap[encoding] : null;
148 if (extension) {
149 const precompressed = new Fs.File(`${response.source.path}${extension}`);
150 let stat;
151 try {
152 stat = await precompressed.openStat('r');
153 }
154 catch (err) {
155 precompressed.close();
156 Bounce.ignore(err, 'boom');
157 }
158
159 if (stat) {
160 response.source.file.close();
161 response.source.file = precompressed;
162
163 response.bytes(stat.size);
164 response.header('content-encoding', encoding);
165 response.vary('accept-encoding');
166 }
167 }
168 }
169
170 return internals.createStream(response);
171};
172
173
174internals.addContentRange = function (response) {
175
176 const request = response.request;
177 const length = response.headers['content-length'];
178 let range = null;
179
180 if (request.route.settings.response.ranges) {
181 if (request.headers.range && length) {
182
183 // Check If-Range
184
185 if (!request.headers['if-range'] ||
186 request.headers['if-range'] === response.headers.etag) { // Ignoring last-modified date (weak)
187
188 // Check that response is not encoded once transmitted
189
190 const mime = request.server.mime.type(response.headers['content-type'] || 'application/octet-stream');
191 const encoding = (request.server.settings.compression && mime.compressible && !response.headers['content-encoding'] ? request.info.acceptEncoding : null);
192
193 if (encoding === 'identity' || !encoding) {
194
195 // Parse header
196
197 const ranges = Ammo.header(request.headers.range, length);
198 if (!ranges) {
199 const error = Boom.rangeNotSatisfiable();
200 error.output.headers['content-range'] = 'bytes */' + length;
201 throw error;
202 }
203
204 // Prepare transform
205
206 if (ranges.length === 1) { // Ignore requests for multiple ranges
207 range = ranges[0];
208 response.code(206);
209 response.bytes(range.to - range.from + 1);
210 response.header('content-range', 'bytes ' + range.from + '-' + range.to + '/' + length);
211 }
212 }
213 }
214 }
215
216 response.header('accept-ranges', 'bytes');
217 }
218
219 return range;
220};
221
222
223internals.createStream = function (response) {
224
225 const source = response.source;
226
227 Hoek.assert(source.file !== null);
228
229 const range = internals.addContentRange(response);
230
231 const options = {
232 start: source.settings.start || 0,
233 end: source.settings.end
234 };
235
236 if (range) {
237 options.end = range.to + options.start;
238 options.start = range.from + options.start;
239 }
240
241 return source.file.createReadStream(options);
242};
243
244
245internals.close = function (response) {
246
247 if (response.source.file !== null) {
248 response.source.file.close();
249 response.source.file = null;
250 }
251};