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