You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

371 lines
11 KiB

8 years ago
  1. #! /usr/bin/env node
  2. var path = require('path'),
  3. fs = require('fs'),
  4. url = require('url'),
  5. mime = require('mime'),
  6. urlJoin = require('url-join'),
  7. showDir = require('./ecstatic/showdir'),
  8. version = JSON.parse(
  9. fs.readFileSync(__dirname + '/../package.json').toString()
  10. ).version,
  11. status = require('./ecstatic/status-handlers'),
  12. generateEtag = require('./ecstatic/etag'),
  13. optsParser = require('./ecstatic/opts');
  14. var ecstatic = module.exports = function (dir, options) {
  15. if (typeof dir !== 'string') {
  16. options = dir;
  17. dir = options.root;
  18. }
  19. var root = path.join(path.resolve(dir), '/'),
  20. opts = optsParser(options),
  21. cache = opts.cache,
  22. autoIndex = opts.autoIndex,
  23. baseDir = opts.baseDir,
  24. defaultExt = opts.defaultExt,
  25. handleError = opts.handleError,
  26. headers = opts.headers,
  27. serverHeader = opts.serverHeader,
  28. weakEtags = opts.weakEtags,
  29. handleOptionsMethod = opts.handleOptionsMethod;
  30. opts.root = dir;
  31. if (defaultExt && /^\./.test(defaultExt)) defaultExt = defaultExt.replace(/^\./, '');
  32. // Support hashes and .types files in mimeTypes @since 0.8
  33. if (opts.mimeTypes) {
  34. try {
  35. // You can pass a JSON blob here---useful for CLI use
  36. opts.mimeTypes = JSON.parse(opts.mimeTypes);
  37. } catch (e) {}
  38. if (typeof opts.mimeTypes === 'string') {
  39. mime.load(opts.mimeTypes);
  40. }
  41. else if (typeof opts.mimeTypes === 'object') {
  42. mime.define(opts.mimeTypes);
  43. }
  44. }
  45. return function middleware (req, res, next) {
  46. // Strip any null bytes from the url
  47. while(req.url.indexOf('%00') !== -1) {
  48. req.url = req.url.replace(/\%00/g, '');
  49. }
  50. // Figure out the path for the file from the given url
  51. var parsed = url.parse(req.url);
  52. try {
  53. decodeURIComponent(req.url); // check validity of url
  54. var pathname = decodePathname(parsed.pathname);
  55. }
  56. catch (err) {
  57. return status[400](res, next, { error: err });
  58. }
  59. var file = path.normalize(
  60. path.join(root,
  61. path.relative(
  62. path.join('/', baseDir),
  63. pathname
  64. )
  65. )
  66. ),
  67. gzipped = file + '.gz';
  68. if(serverHeader !== false) {
  69. // Set common headers.
  70. res.setHeader('server', 'ecstatic-'+version);
  71. }
  72. Object.keys(headers).forEach(function (key) {
  73. res.setHeader(key, headers[key])
  74. })
  75. if (req.method === 'OPTIONS' && handleOptionsMethod) {
  76. return res.end();
  77. }
  78. // TODO: This check is broken, which causes the 403 on the
  79. // expected 404.
  80. if (file.slice(0, root.length) !== root) {
  81. return status[403](res, next);
  82. }
  83. if (req.method && (req.method !== 'GET' && req.method !== 'HEAD' )) {
  84. return status[405](res, next);
  85. }
  86. function statFile() {
  87. fs.stat(file, function (err, stat) {
  88. if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
  89. if (req.statusCode == 404) {
  90. // This means we're already trying ./404.html and can not find it.
  91. // So send plain text response with 404 status code
  92. status[404](res, next);
  93. }
  94. else if (!path.extname(parsed.pathname).length && defaultExt) {
  95. // If there is no file extension in the path and we have a default
  96. // extension try filename and default extension combination before rendering 404.html.
  97. middleware({
  98. url: parsed.pathname + '.' + defaultExt + ((parsed.search)? parsed.search:'')
  99. }, res, next);
  100. }
  101. else {
  102. // Try to serve default ./404.html
  103. middleware({
  104. url: (handleError ? ('/' + path.join(baseDir, '404.' + defaultExt)) : req.url),
  105. statusCode: 404
  106. }, res, next);
  107. }
  108. }
  109. else if (err) {
  110. status[500](res, next, { error: err });
  111. }
  112. else if (stat.isDirectory()) {
  113. // 302 to / if necessary
  114. if (!parsed.pathname.match(/\/$/)) {
  115. res.statusCode = 302;
  116. res.setHeader('location', parsed.pathname + '/' +
  117. (parsed.query? ('?' + parsed.query):'')
  118. );
  119. return res.end();
  120. }
  121. if (autoIndex) {
  122. return middleware({
  123. url: urlJoin(encodeURIComponent(pathname), '/index.' + defaultExt)
  124. }, res, function (err) {
  125. if (err) {
  126. return status[500](res, next, { error: err });
  127. }
  128. if (opts.showDir) {
  129. return showDir(opts, stat)(req, res);
  130. }
  131. return status[403](res, next);
  132. });
  133. }
  134. if (opts.showDir) {
  135. return showDir(opts, stat)(req, res);
  136. }
  137. status[404](res, next);
  138. }
  139. else {
  140. serve(stat);
  141. }
  142. });
  143. }
  144. // Look for a gzipped file if this is turned on
  145. if (opts.gzip && shouldCompress(req)) {
  146. fs.stat(gzipped, function (err, stat) {
  147. if (!err && stat.isFile()) {
  148. file = gzipped;
  149. return serve(stat);
  150. } else {
  151. statFile();
  152. }
  153. });
  154. } else {
  155. statFile();
  156. }
  157. function serve(stat) {
  158. // Do a MIME lookup, fall back to octet-stream and handle gzip
  159. // special case.
  160. var defaultType = opts.contentType || 'application/octet-stream',
  161. contentType = mime.lookup(file, defaultType),
  162. charSet;
  163. if (contentType) {
  164. charSet = mime.charsets.lookup(contentType, 'utf-8');
  165. if (charSet) {
  166. contentType += '; charset=' + charSet;
  167. }
  168. }
  169. if (path.extname(file) === '.gz') {
  170. res.setHeader('Content-Encoding', 'gzip');
  171. // strip gz ending and lookup mime type
  172. contentType = mime.lookup(path.basename(file, ".gz"), defaultType);
  173. }
  174. var range = (req.headers && req.headers['range']);
  175. if (range) {
  176. var total = stat.size;
  177. var parts = range.replace(/bytes=/, "").split("-");
  178. var partialstart = parts[0];
  179. var partialend = parts[1];
  180. var start = parseInt(partialstart, 10);
  181. var end = Math.min(total-1, partialend ? parseInt(partialend, 10) : total-1);
  182. var chunksize = (end-start)+1;
  183. if (start > end || isNaN(start) || isNaN(end)) {
  184. return status['416'](res, next);
  185. }
  186. var fstream = fs.createReadStream(file, {start: start, end: end});
  187. fstream.on('error', function (err) {
  188. status['500'](res, next, { error: err });
  189. });
  190. res.on('close', function () {
  191. fstream.destroy();
  192. });
  193. res.writeHead(206, {
  194. 'Content-Range': 'bytes ' + start + '-' + end + '/' + total,
  195. 'Accept-Ranges': 'bytes',
  196. 'Content-Length': chunksize,
  197. 'Content-Type': contentType
  198. });
  199. fstream.pipe(res);
  200. return;
  201. }
  202. // TODO: Helper for this, with default headers.
  203. var lastModified = (new Date(stat.mtime)).toUTCString(),
  204. etag = generateEtag(stat, weakEtags);
  205. res.setHeader('last-modified', lastModified);
  206. res.setHeader('etag', etag);
  207. if (typeof cache === 'function') {
  208. var requestSpecificCache = cache(pathname);
  209. if (typeof requestSpecificCache === 'number') {
  210. requestSpecificCache = 'max-age=' + requestSpecificCache;
  211. }
  212. res.setHeader('cache-control', requestSpecificCache);
  213. } else {
  214. res.setHeader('cache-control', cache);
  215. }
  216. // Return a 304 if necessary
  217. if (shouldReturn304(req, lastModified, etag)) {
  218. return status[304](res, next);
  219. }
  220. res.setHeader('content-length', stat.size);
  221. res.setHeader('content-type', contentType);
  222. // set the response statusCode if we have a request statusCode.
  223. // This only can happen if we have a 404 with some kind of 404.html
  224. // In all other cases where we have a file we serve the 200
  225. res.statusCode = req.statusCode || 200;
  226. if (req.method === "HEAD") {
  227. return res.end();
  228. }
  229. var stream = fs.createReadStream(file);
  230. stream.pipe(res);
  231. stream.on('error', function (err) {
  232. status['500'](res, next, { error: err });
  233. });
  234. }
  235. function shouldReturn304(req, serverLastModified, serverEtag) {
  236. if (!req || !req.headers) {
  237. return false;
  238. }
  239. var clientModifiedSince = req.headers['if-modified-since'],
  240. clientEtag = req.headers['if-none-match'];
  241. if (!clientModifiedSince && !clientEtag) {
  242. // Client did not provide any conditional caching headers
  243. return false;
  244. }
  245. if (clientModifiedSince) {
  246. // Catch "illegal access" dates that will crash v8
  247. // https://github.com/jfhbrook/node-ecstatic/pull/179
  248. try {
  249. var clientModifiedDate = new Date(Date.parse(clientModifiedSince));
  250. }
  251. catch (err) { return false }
  252. if (clientModifiedDate.toString() === 'Invalid Date') {
  253. return false;
  254. }
  255. // If the client's copy is older than the server's, don't return 304
  256. if (clientModifiedDate < new Date(serverLastModified)) {
  257. return false;
  258. }
  259. }
  260. if (clientEtag) {
  261. // Do a strong or weak etag comparison based on setting
  262. // https://www.ietf.org/rfc/rfc2616.txt Section 13.3.3
  263. if (opts.weakCompare && clientEtag !== serverEtag
  264. && clientEtag !== ('W/' + serverEtag) && ('W/' + clientEtag) !== serverEtag) {
  265. return false;
  266. } else if (!opts.weakCompare && (clientEtag !== serverEtag || clientEtag.indexOf('W/') === 0)) {
  267. return false;
  268. }
  269. }
  270. return true;
  271. }
  272. };
  273. };
  274. ecstatic.version = version;
  275. ecstatic.showDir = showDir;
  276. // Check to see if we should try to compress a file with gzip.
  277. function shouldCompress(req) {
  278. var headers = req.headers;
  279. return headers && headers['accept-encoding'] &&
  280. headers['accept-encoding']
  281. .split(",")
  282. .some(function (el) {
  283. return ['*','compress', 'gzip', 'deflate'].indexOf(el) != -1;
  284. })
  285. ;
  286. }
  287. // See: https://github.com/jesusabdullah/node-ecstatic/issues/109
  288. function decodePathname(pathname) {
  289. var pieces = pathname.replace(/\\/g,"/").split('/');
  290. return pieces.map(function (piece) {
  291. piece = decodeURIComponent(piece);
  292. if (process.platform === 'win32' && /\\/.test(piece)) {
  293. throw new Error('Invalid forward slash character');
  294. }
  295. return piece;
  296. }).join('/');
  297. }
  298. if (!module.parent) {
  299. var defaults = require('./ecstatic/defaults.json')
  300. var http = require('http'),
  301. opts = require('minimist')(process.argv.slice(2), {
  302. alias: require('./ecstatic/aliases.json'),
  303. default: defaults,
  304. boolean: Object.keys(defaults).filter(function (key) {
  305. return typeof defaults[key] === 'boolean'
  306. })
  307. }),
  308. envPORT = parseInt(process.env.PORT, 10),
  309. port = envPORT > 1024 && envPORT <= 65536 ? envPORT : opts.port || opts.p || 8000,
  310. dir = opts.root || opts._[0] || process.cwd();
  311. if (opts.help || opts.h) {
  312. var u = console.error;
  313. u('usage: ecstatic [dir] {options} --port PORT');
  314. u('see https://npm.im/ecstatic for more docs');
  315. return;
  316. }
  317. http.createServer(ecstatic(dir, opts))
  318. .listen(port, function () {
  319. console.log('ecstatic serving ' + dir + ' at http://0.0.0.0:' + port);
  320. });
  321. }