1 | 'use strict'
|
2 |
|
3 | const fs = require('fs')
|
4 | const { promisify } = require('util')
|
5 | const mime = require('../detect/mime')
|
6 | const { basename, dirname, isAbsolute, join } = require('path')
|
7 | const { format: formatLastModified } = require('../lastModified')
|
8 |
|
9 | const bin = 'application/octet-stream'
|
10 |
|
11 | const cfs = 'custom-file-system'
|
12 | const matchcase = 'case-sensitive'
|
13 | const i404 = 'ignore-if-not-found'
|
14 | const cache = 'caching-strategy'
|
15 | const cmt = 'mime-types'
|
16 |
|
17 | const nodeFs = {
|
18 | stat: promisify(fs.stat),
|
19 | readdir: promisify(fs.readdir),
|
20 | createReadStream: (path, options) => Promise.resolve(fs.createReadStream(path, options))
|
21 | }
|
22 |
|
23 | function 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 |
|
39 | function 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] ) {
|
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 |
|
58 | function 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 |
|
91 | async 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 |
|
101 | async 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 |
|
113 | async function checkStrictPath (filePath) {
|
114 | if (filePath.includes('//')) {
|
115 | throw new Error('Empty folder')
|
116 | }
|
117 | return checkCaseSensitivePath.call(this, filePath)
|
118 | }
|
119 |
|
120 | module.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]
|
167 | if (!isAbsolute(filePath)) {
|
168 | filePath = join(mapping.cwd, filePath)
|
169 | }
|
170 | const directoryAccess = !!filePath.match(/(\\|\/)$/)
|
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
|
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 | }
|