UNPKG

9.21 kBJavaScriptView Raw
1'use strict';
2
3var fs = require('fs');
4var path = require('path');
5var url = require('url');
6var send = require('send');
7var mime = require('mime');
8var merge = require('merge');
9var async = require('async');
10var etag = require('etag');
11
12var utils = require('./utils');
13var CacheChain = require('./cache-chain');
14var TransformChain = require('./transform-chain');
15
16var Cache = require('@donotjs/donot-cache');
17var Transform = require('@donotjs/donot-transform');
18
19class Donot {
20 /**
21 * Constructor for Donot
22 * @param {string} root root of served directory
23 * @param {object} opt options object
24 * @return {Donot} instance of Donot
25 */
26 constructor(root, opt) {
27 // Check arguments
28 if (typeof root !== 'string') {
29 throw new TypeError('root is required and must be a string');
30 }
31
32 this.root = root;
33
34 if (opt && typeof opt !== 'object') {
35 throw new TypeError('opt must be an object');
36 }
37
38 opt = opt || {};
39
40 // Copy options
41 this.options = {};
42
43 // Setup default options
44 this.options.index = opt.index || ['index.html'];
45 this.options.etag = opt.etag !== false;
46 this.options.lastModified = opt.lastModified !== false;
47 this.options.dotFiles = opt.dotFiles === true;
48 this.options.templates = opt.templates === true;
49 this.options.serveDir = opt.serveDir || '/';
50 this.options.accessControl = opt.accessControl || { deny: [] };
51
52 // Add transforms
53 this.transforms = [];
54 (opt.transforms || []).forEach((e) => {
55 this.transform(e);
56 });
57
58 // Set default dummy cache
59 this.options.cache = opt.cache || new Cache();
60
61 // Wrap cache in array if not already.
62 if (this.options.cache.constructor.name !== 'Array') {
63 this.options.cache = [this.options.cache];
64 }
65
66 // Check serveDir
67 if (typeof this.options.serveDir !== 'string') {
68 throw new TypeError('serveDir must be a string');
69 }
70
71 // Normalize serveDir
72 this.options.serveDir = path.normalize('/' + this.options.serveDir + '/');
73
74 // Check access control
75 if (typeof this.options.accessControl !== 'object') {
76 throw new TypeError('accessControl must be of type object');
77 }
78
79 // Check access control is both allow or deny
80 if (typeof this.options.accessControl.deny !== 'undefined' &&
81 typeof this.options.accessControl.allow !== 'undefined') {
82 throw new TypeError('accessControl cannot have both allow and deny.');
83 }
84
85 // Check deny
86 if (this.options.accessControl.deny &&
87 (typeof this.options.accessControl.deny !== 'object' ||
88 this.options.accessControl.deny.constructor.name !== 'Array')) {
89 throw new TypeError('accessControl.deny must be of type array');
90 }
91
92 // Check allow
93 if (this.options.accessControl.allow &&
94 (typeof this.options.accessControl.allow !== 'object' ||
95 this.options.accessControl.allow.constructor.name !== 'Array')) {
96 throw new TypeError('accessControl.deny must be of type array');
97 }
98
99 // Apply default if either allow or deny is set
100 if (!this.options.accessControl.deny && !this.options.accessControl.allow) {
101 this.options.accessControl.deny = [];
102 }
103
104 // Check allow and deny items
105 var checkArray = (arr) => {
106 if (typeof arr == 'undefined') return true;
107 for (var idx in arr) {
108 if ((typeof arr[idx] !== 'object' || arr[idx].constructor.name !== 'RegExp') && typeof arr[idx] !== 'string') {
109 return false;
110 }
111 }
112 return true;
113 };
114
115 if (!checkArray(this.options.accessControl.deny)) {
116 throw new TypeError('accessControl.deny can only contain Strings and RegExps');
117 }
118
119 if (!checkArray(this.options.accessControl.allow)) {
120 throw new TypeError('accessControl.allow can only contain Strings and RegExps');
121 }
122
123 // Check cache objects
124 this.options.cache.forEach((cache) => {
125 if (!utils.isKindOf(cache, Cache)) {
126 throw new TypeError('cache must be of type Cache.');
127 }
128 });
129
130 // Create CacheChain
131 this.cacheChain = new CacheChain(this.options.cache, this.root, this.options.serveDir);
132
133 // We make sure root exists and is a directory
134 var rootStat = fs.statSync(root); // Will throw if root does not exist
135 if (!rootStat.isDirectory()) throw new Error('root is not a directory');
136 }
137
138 /**
139 * Test access to file
140 * @param {[type]} file The path and filename of the file to test
141 * @return {[type]} Boolean indicating if access is allowed
142 * @api private
143 */
144 testAccess(filename) {
145
146 var allows = (typeof this.options.accessControl.allow !== 'undefined');
147 var list = this.options.accessControl.allow || this.options.accessControl.deny;
148
149 for (var idx in list) {
150 var item = list[idx];
151
152 // File extension
153 if (typeof item === 'string') {
154 item = new RegExp(utils.escapeRegExp(item) + '$', 'i');
155 }
156
157 // Regular expression
158 if (item.test(filename)) {
159 return allows;
160 }
161 }
162
163 return !allows;
164
165 }
166
167 /**
168 * Adds a transform
169 * @param {object} transform tranform
170 * @param {object} options options
171 * @api public
172 */
173 transform(transform, options) {
174
175 if (!transform || !utils.isKindOf(transform, Transform)) {
176 throw new TypeError('transform is required and must be an instance of Transform');
177 }
178
179 this.transforms.push(transform);
180
181 }
182
183 render(remoteFilename, ctx) {
184 return new Promise((resolved, rejected) => {
185
186 if (!remoteFilename || typeof remoteFilename !== 'string') {
187 return rejected(TypeError('filename is required and must be a string'));
188 }
189
190 (new TransformChain(this.transforms, this.cacheChain))
191 .render(utils.remoteToLocalFilename(remoteFilename, this.root, this.options.serveDir),
192 remoteFilename,
193 ctx)
194 .then(resolved, rejected);
195
196 });
197 }
198
199 route(req, res, next) {
200
201 // Set default next handler if used without Express or Connect
202 if (!next) {
203 next = (err) => {
204 res.statusCode = err ? (err.status || 500) : 404;
205 res.end(err ? err.stack : 'not found');
206 };
207 }
208
209 // Ignore non-GET and non-HEAD requests.
210 if (req.method != 'GET' && req.method != 'HEAD') return next();
211
212 // Convert URL to remote path
213 var remoteFilename = url.parse(req.url).pathname;
214 var localFilename = utils.remoteToLocalFilename(remoteFilename, this.root, this.options.serveDir);
215
216 // If not in path
217 if (!localFilename) return next();
218
219 // Test access
220 if (!this.testAccess(remoteFilename)) return next();
221
222 // Ask transform
223 if (!this.options.templates) {
224 if (this.transforms.some((transform) => {
225 return !transform.allowAccess(remoteFilename);
226 })) {
227 return next();
228 }
229 }
230
231 // Ignore hidden files and folders
232 if (!this.options.dotFiles) {
233 var parts = localFilename.split(path.sep);
234 for (var idx in parts) {
235 if (parts[idx].substr(0, 1) == '.') {
236 return next();
237 }
238 }
239 }
240
241 // Check if file exists
242 fs.exists(localFilename, (exists) => {
243
244 // File does not exist
245 if (!exists) {
246
247 var sourceMapTester = /\.map$/i;
248 var sourceMap = sourceMapTester.test(localFilename);
249 if (sourceMap) {
250 localFilename = localFilename.replace(sourceMapTester, '');
251 remoteFilename = remoteFilename.replace(sourceMapTester, '');
252 }
253
254 return this.render(remoteFilename, req).then((result) => {
255
256 // If no result - next route
257 if (!result) return next();
258
259 // If source map requested - make data source map
260 if (sourceMap) {
261 result.data = result.map ? new Buffer(JSON.stringify(result.map)) : null;
262 result.filename += '.min';
263 }
264
265 // If no data - next route
266 if (!result.data) return next();
267
268 // - else send response
269 res.setHeader('Content-Type', mime.lookup(result.filename) + '; charset=UTF-8');
270
271 // Set Last-Modified header
272 if (this.options.lastModified === true) {
273 res.setHeader('Last-Modified', result.modificationDate.toUTCString());
274 }
275
276 // Use Etag cache control if enabled.
277 if (this.options.etag === true) {
278 // Generate etag
279 var tag = etag(result.data);
280 res.setHeader('Etag', tag);
281
282 // If client sent If-None-Match and it matches etag - send 304 Not Modified
283 if (req.headers['if-none-match'] === tag) {
284 res.statusCode = 304;
285 return res.end();
286 }
287
288 }
289
290 // - else just send
291 return res.end(result.data);
292
293 }, next);
294 }
295
296 // File exists
297 else {
298
299 // Get stats
300 fs.stat(localFilename, (err, stats) => {
301 if (err) return next(err);
302
303 // If directory we iterate through indexes
304 if (stats.isDirectory()) {
305
306 // Save origin url
307 var originalUrl = req.url;
308 return async.eachSeries(this.options.index, (index, next) => {
309
310 // Update and assign to new url
311 var urlObj = url.parse(originalUrl);
312 urlObj.pathname = path.normalize(urlObj.pathname + '/' + index);
313 req.url = url.format(urlObj);
314
315 // Route to the new url
316 this.route(req, res, next);
317
318 }, next);
319
320 } // end if directory
321
322 // We did not match any template - send file.
323 // - We allow for hidden files as we safe guarded for it above
324 // - also indexes are handled above so we also disable that.
325 send(req, localFilename, {
326 dotfiles: 'allow',
327 index: false,
328 lastModified: true,
329 etag: this.options.etag
330 }).pipe(res);
331
332 });
333
334 }
335
336 });
337
338 }
339
340}
341
342// Expose Donot
343exports = module.exports = Donot;