UNPKG

6.42 kBJavaScriptView Raw
1'use strict'
2
3const fs = require('fs')
4const { promisify } = require('util')
5const mime = require('../detect/mime')
6const { basename, dirname, isAbsolute, join } = require('path')
7const { format: formatLastModified } = require('../lastModified')
8
9const bin = 'application/octet-stream'
10
11const cfs = 'custom-file-system'
12const matchcase = 'case-sensitive'
13const i404 = 'ignore-if-not-found'
14const cache = 'caching-strategy'
15const cmt = 'mime-types'
16
17const nodeFs = {
18 stat: promisify(fs.stat),
19 readdir: promisify(fs.readdir),
20 createReadStream: (path, options) => Promise.resolve(fs.createReadStream(path, options))
21}
22
23function processCache (request, cachingStrategy, { mtime }) {
24 if (cachingStrategy === 'modified') {
25 const lastModified = formatLastModified(mtime)
26 const modifiedSince = request.headers['if-modified-since']
27 let status
28 if (modifiedSince && lastModified === modifiedSince) {
29 status = 304
30 }
31 return { header: { 'cache-control': 'no-cache', 'last-modified': lastModified }, status }
32 }
33 if (cachingStrategy > 0) {
34 return { header: { 'cache-control': `public, max-age=${cachingStrategy}, immutable` } }
35 }
36 return { header: { 'cache-control': 'no-store' } }
37}
38
39function processBytesRange (request, { mtime, size }) {
40 const bytesRange = /bytes=(\d+)-(\d+)?(,)?/.exec(request.headers.range)
41 const ifRange = request.headers['if-range']
42 if ((!ifRange || ifRange === formatLastModified(mtime)) && bytesRange && !bytesRange[3] /* Multipart not supported */) {
43 const start = parseInt(bytesRange[1], 10)
44 let end
45 if (bytesRange[2]) {
46 end = parseInt(bytesRange[2], 10)
47 } else {
48 end = size - 1
49 }
50 if (start > end || start >= size) {
51 return { status: 416, contentLength: 0 }
52 }
53 return { start, end, header: { 'content-range': `bytes ${start}-${end}/${size}` }, status: 206, contentLength: end - start + 1 }
54 }
55 return { status: 200, contentLength: size }
56}
57
58function sendFile ({ cachingStrategy, mapping, request, response, fs, filePath }, fileStat) {
59 return new Promise((resolve, reject) => {
60 const { header: cacheHeader, status: cacheStatus } = processCache(request, cachingStrategy, fileStat)
61 const { start, end, header: rangeHeader, status: rangeStatus, contentLength } = processBytesRange(request, fileStat)
62 const status = cacheStatus || rangeStatus
63 const fileExtension = (/\.([^.]*)$/.exec(filePath) || [])[1]
64 const mimeType = mapping[cmt][fileExtension] || mime(fileExtension) || bin
65 response.writeHead(status, {
66 'content-type': mimeType,
67 'content-length': contentLength,
68 'accept-ranges': 'bytes',
69 ...rangeHeader,
70 ...cacheHeader
71 })
72 if (request.method === 'HEAD' || contentLength === 0 || request.aborted || status === 304) {
73 response.end()
74 resolve()
75 return
76 }
77 response.on('finish', resolve)
78 fs.createReadStream(filePath, { start, end })
79 .then(stream => {
80 if (request.aborted) {
81 stream.destroy()
82 response.end()
83 resolve()
84 } else {
85 stream.on('error', reject).pipe(response)
86 }
87 })
88 })
89}
90
91async function sendIndex (context) {
92 const filePath = join(context.filePath, 'index.html')
93 await context.checkPath(filePath)
94 const stat = await context.fs.stat(filePath)
95 if (stat.isDirectory()) {
96 throw new Error('index.html not a file')
97 }
98 return sendFile({ ...context, filePath }, stat)
99}
100
101async function checkCaseSensitivePath (filePath) {
102 const folderPath = dirname(filePath)
103 if (folderPath && folderPath !== filePath) {
104 const name = basename(filePath)
105 const names = await this.fs.readdir(folderPath)
106 if (!names.includes(name)) {
107 throw new Error('Not found')
108 }
109 return checkCaseSensitivePath.call(this, folderPath)
110 }
111}
112
113async function checkStrictPath (filePath) {
114 if (filePath.includes('//')) {
115 throw new Error('Empty folder')
116 }
117 return checkCaseSensitivePath.call(this, filePath)
118}
119
120module.exports = {
121 schema: {
122 [matchcase]: {
123 type: 'boolean',
124 defaultValue: false
125 },
126 [cfs]: {
127 types: ['string', 'object'],
128 defaultValue: nodeFs
129 },
130 [i404]: {
131 type: 'boolean',
132 defaultValue: false
133 },
134 [cache]: {
135 types: ['string', 'number'],
136 defaultValue: 0
137 },
138 strict: {
139 type: 'boolean',
140 defaultValue: false
141 },
142 [cmt]: {
143 type: 'object',
144 defaultValue: {}
145 }
146 },
147 method: 'GET,HEAD',
148 validate: async mapping => {
149 if (typeof mapping[cfs] === 'string') {
150 mapping[cfs] = require(join(mapping.cwd, mapping[cfs]))
151 }
152 const apis = ['stat', 'createReadStream']
153 if (mapping[matchcase]) {
154 apis.push('readdir')
155 }
156 const invalids = apis.filter(name => typeof mapping[cfs][name] !== 'function')
157 if (invalids.length) {
158 throw new Error(`Invalid custom-file-system specification (${invalids.join(', ')})`)
159 }
160 const cachingStrategy = mapping[cache]
161 if (typeof cachingStrategy === 'string' && cachingStrategy !== 'modified') {
162 throw new Error('Invalid caching-strategy name')
163 }
164 },
165 redirect: ({ request, mapping, redirect, response }) => {
166 let filePath = /([^?#]+)/.exec(unescape(redirect))[1] // filter URL parameters & hash
167 if (!isAbsolute(filePath)) {
168 filePath = join(mapping.cwd, filePath)
169 }
170 const directoryAccess = !!filePath.match(/(\\|\/)$/) // Test known path separators
171 if (directoryAccess) {
172 filePath = filePath.substring(0, filePath.length - 1)
173 }
174 const context = {
175 cachingStrategy: mapping[cache],
176 fs: mapping[cfs],
177 filePath,
178 mapping,
179 request,
180 response
181 }
182 if (mapping.strict) {
183 context.checkPath = checkStrictPath
184 } else if (mapping[matchcase]) {
185 context.checkPath = checkCaseSensitivePath
186 } else {
187 context.checkPath = async () => {}
188 }
189 return context.fs.stat(filePath)
190 .then(async stat => {
191 await context.checkPath(filePath, mapping.strict)
192 const isDirectory = stat.isDirectory()
193 if (isDirectory ^ directoryAccess) {
194 return 404 // Can't ignore if not found
195 }
196 if (isDirectory) {
197 return sendIndex(context)
198 }
199 return sendFile(context, stat)
200 })
201 .catch(() => {
202 if (!mapping[i404]) {
203 return 404
204 }
205 })
206 }
207}