1 | 'use strict';
|
2 |
|
3 | const Path = require('path');
|
4 |
|
5 | const Ammo = require('@hapi/ammo');
|
6 | const Boom = require('@hapi/boom');
|
7 | const Bounce = require('@hapi/bounce');
|
8 | const Hoek = require('@hapi/hoek');
|
9 | const Validate = require('@hapi/validate');
|
10 |
|
11 | const Etag = require('./etag');
|
12 | const Fs = require('./fs');
|
13 |
|
14 |
|
15 | const internals = {};
|
16 |
|
17 |
|
18 | internals.defaultMap = {
|
19 | gzip: '.gz'
|
20 | };
|
21 |
|
22 |
|
23 | internals.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 |
|
41 | exports.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 |
|
58 | exports.load = function (path, request, options) {
|
59 |
|
60 | const response = exports.response(path, options, request, true);
|
61 | return internals.prepare(response);
|
62 | };
|
63 |
|
64 |
|
65 | exports.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 |
|
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 |
|
95 | internals.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 |
|
138 | internals.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 |
|
174 | internals.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 |
|
184 |
|
185 | if (!request.headers['if-range'] ||
|
186 | request.headers['if-range'] === response.headers.etag) {
|
187 |
|
188 |
|
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 |
|
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 |
|
205 |
|
206 | if (ranges.length === 1) {
|
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 |
|
223 | internals.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 |
|
245 | internals.close = function (response) {
|
246 |
|
247 | if (response.source.file !== null) {
|
248 | response.source.file.close();
|
249 | response.source.file = null;
|
250 | }
|
251 | };
|