UNPKG

send-stream

Version:

Streaming file serving library with Range and conditional-GET support from file system or any streaming sources.

477 lines 19.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.FileSystemStorage = exports.GenericFileSystemStorage = exports.FORBIDDEN_CHARACTERS = void 0; exports.escapeHTMLInPath = escapeHTMLInPath; const node_fs_1 = require("node:fs"); const node_path_1 = require("node:path"); const node_stream_1 = require("node:stream"); const node_util_1 = require("node:util"); // eslint-disable-next-line n/prefer-global/url const node_url_1 = require("node:url"); const storage_1 = require("./storage"); const errors_1 = require("./errors"); const utils_1 = require("./utils"); const file_system_errors_1 = require("./file-system-errors"); /** * Escape HTML in path for this library (only replace & character since ", < and > are already excluded) * @param path - the path to escape * @returns the escaped path */ function escapeHTMLInPath(path) { // & is the only character to escape. '<', '>' and '"' are already excluded from listing return path.replace(/&/ug, '&amp;'); } // eslint-disable-next-line no-control-regex, @typescript-eslint/no-inferrable-types, sonarjs/no-control-regex exports.FORBIDDEN_CHARACTERS = /[/?<>\\*|":\u0000-\u001F\u0080-\u009F]/u; /** * File system storage * @template FileDescriptor - file descriptor type */ class GenericFileSystemStorage extends storage_1.Storage { /** * Create file system storage * @param root - root folder path * @param opts - file system storage options */ constructor(root, opts) { super(opts); /** * Root directory */ Object.defineProperty(this, "root", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** * Content encoding mappings array (or false if disabled) */ Object.defineProperty(this, "contentEncodingMappings", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** * Ignore pattern (or false if disabled) */ Object.defineProperty(this, "ignorePattern", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** * On directory action * * - 'serve-index' to serve directory's index.html * - 'list-files' to list directory files * - (or false if disabled) */ Object.defineProperty(this, "onDirectory", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** * fs.open function */ Object.defineProperty(this, "fsOpen", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** * fs.fstat function */ Object.defineProperty(this, "fsFstat", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** * fs.close function */ Object.defineProperty(this, "fsClose", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** * fs.createReadStream function */ Object.defineProperty(this, "fsCreateReadStream", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** * fs.opendir function */ Object.defineProperty(this, "fsOpendir", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** * fs.readdir function */ Object.defineProperty(this, "fsReaddir", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** * fs.constants constants */ Object.defineProperty(this, "fsConstants", { enumerable: true, configurable: true, writable: true, value: void 0 }); const { contentEncodingMappings, ignorePattern, onDirectory, fsModule } = opts; this.root = root; this.contentEncodingMappings = contentEncodingMappings ? contentEncodingMappings.map(encodingConfig => { const encodingPreferences = new Map(encodingConfig.encodings.map(({ name, path }, order) => [name, { path, order }])); let identityEncodingPreference = encodingPreferences.get('identity'); if (!identityEncodingPreference) { identityEncodingPreference = { path: '$&', order: encodingConfig.encodings.length }; encodingPreferences.set('identity', identityEncodingPreference); } const matcher = encodingConfig.matcher instanceof RegExp ? encodingConfig.matcher : new RegExp(encodingConfig.matcher, 'u'); return { matcher, encodingPreferences, identityEncodingPreference }; }) : false; this.ignorePattern = ignorePattern === undefined ? /^\./u : ignorePattern === false || ignorePattern instanceof RegExp ? ignorePattern : new RegExp(ignorePattern, 'u'); this.onDirectory = onDirectory ?? false; this.fsOpen = (0, node_util_1.promisify)(fsModule.open); this.fsFstat = (0, node_util_1.promisify)(fsModule.fstat); this.fsClose = (0, node_util_1.promisify)(fsModule.close); this.fsCreateReadStream = fsModule.createReadStream; this.fsOpendir = fsModule.opendir ? (0, node_util_1.promisify)(fsModule.opendir) : undefined; this.fsReaddir = (0, node_util_1.promisify)(fsModule.readdir); this.fsConstants = fsModule.constants; } /** * Parse and check url encoded path or path array * @param path - url encoded path or path array to be accessed from root * @returns path array * @throws when the path can not be parsed */ parsePath(path) { let pathParts; if (typeof path === 'string') { if (!path.startsWith('/')) { throw new errors_1.StorageError(`'${path}' is not a valid path (should start with '/')`, path); } const { pathname, search } = new node_url_1.URL(`http://localhost${path}`); pathParts = pathname.split('/'); try { pathParts = pathParts.map(decodeURIComponent); } catch (err) { throw new file_system_errors_1.MalformedPathError(String(err), path, pathParts); } const normalizedPath = pathname + search; if (path !== normalizedPath) { throw new file_system_errors_1.NotNormalizedError(`${path} is not normalized`, path, pathParts, normalizedPath); } } else { pathParts = path; if (pathParts.length === 0 || pathParts[0] !== '' || pathParts.some(part => /^\.\.?$/u.test(part))) { const pathArray = path.map(v => `'${v}'`).join(', '); throw new file_system_errors_1.InvalidPathError(`[${pathArray}] is not a valid path array (should start with '' and not contain '..' or '.')`, path, pathParts); } } const emptyPartIndex = pathParts.indexOf('', 1); let haveTrailingSlash = false; // trailing or consecutive slashes if (emptyPartIndex !== -1) { if (emptyPartIndex !== pathParts.length - 1) { throw new file_system_errors_1.ConsecutiveSlashesError(`${String(path)} have two consecutive slashes`, path, pathParts); } haveTrailingSlash = true; } // slashes or null bytes if (pathParts.some(v => exports.FORBIDDEN_CHARACTERS.test(v))) { throw new file_system_errors_1.ForbiddenCharacterError(`${String(path)} has one or more forbidden characters`, path, pathParts); } // ignored files const { ignorePattern } = this; if (ignorePattern && pathParts.some(v => ignorePattern.test(v))) { throw new file_system_errors_1.IgnoredFileError(`${String(path)} is ignored`, path, pathParts); } // trailing slash if (haveTrailingSlash) { const untrailedPathParts = pathParts.slice(0, -1); const { onDirectory } = this; if (onDirectory === 'list-files') { pathParts = untrailedPathParts; } else if (onDirectory === 'serve-index') { pathParts = [...untrailedPathParts, 'index.html']; haveTrailingSlash = false; } else { throw new file_system_errors_1.TrailingSlashError(`${String(path)} have a trailing slash`, path, pathParts, untrailedPathParts); } } return { pathParts, haveTrailingSlash }; } /** * Open file, return undefined if does not exist * @param path - file path * @returns file handle */ async safeOpen(path) { let fd; try { fd = await this.fsOpen(path, this.fsConstants.O_RDONLY); } catch { // noop if an error happens while trying to open file } return fd; } /** * Get Stat object from file descriptor * @param fd - file descriptor * @param _path - file path (unused but can be useful for caching on override) * @returns Stat object */ async stat(fd, _path) { return this.fsFstat(fd); } /** * Close file descriptor * @param fd - file descriptor * @param _path - file path (unused but can be useful for caching on override) * @returns Stat object */ async earlyClose(fd, _path) { return this.fsClose(fd); } /** * Open file and retrieve storage information (filename, modification date, size, ...) * @param path - file path * @param requestHeaders - request headers * @returns StorageInfo object * @throws when the file can not be opened */ async open(path, requestHeaders) { let fd; const { pathParts, haveTrailingSlash } = this.parsePath(path); let resolvedPath = (0, node_path_1.join)(this.root, ...pathParts); let stats; let vary; let contentEncoding; try { const { contentEncodingMappings: encodingsMappings } = this; let selectedEncodingMapping; // test path against encoding map if (!haveTrailingSlash && encodingsMappings) { selectedEncodingMapping = encodingsMappings.find(encodingMapping => encodingMapping.matcher.test(resolvedPath)); } if (selectedEncodingMapping) { const { encodingPreferences, identityEncodingPreference, matcher } = selectedEncodingMapping; // if path can have encoded version vary = 'Accept-Encoding'; const acceptableEncodings = (0, utils_1.acceptEncodings)(requestHeaders['accept-encoding'], encodingPreferences, identityEncodingPreference); for (const [acceptableEncodingName, { path: acceptableEncodingPath }] of acceptableEncodings) { const encodedPath = resolvedPath.replace(matcher, acceptableEncodingPath); // eslint-disable-next-line no-await-in-loop fd = await this.safeOpen(encodedPath); if (fd === undefined) { continue; } // eslint-disable-next-line no-await-in-loop stats = await this.stat(fd, encodedPath); if (stats.isDirectory()) { if (acceptableEncodingName === 'identity') { throw new file_system_errors_1.IsDirectoryError(`${resolvedPath} is a directory`, path, pathParts, resolvedPath); } const directoryFd = fd; fd = undefined; stats = undefined; // eslint-disable-next-line no-await-in-loop await this.earlyClose(directoryFd, encodedPath); continue; } contentEncoding = acceptableEncodingName === 'identity' ? undefined : acceptableEncodingName; resolvedPath = encodedPath; break; } if (fd === undefined || !stats) { throw new file_system_errors_1.DoesNotExistError(`${resolvedPath} does not exist`, path, pathParts, resolvedPath); } } else { // if path can not have encoded version fd = await this.safeOpen(resolvedPath); if (fd === undefined) { throw new file_system_errors_1.DoesNotExistError(`${resolvedPath} does not exist`, path, pathParts, resolvedPath); } stats = await this.stat(fd, resolvedPath); if (stats.isDirectory()) { if (!haveTrailingSlash) { throw new file_system_errors_1.IsDirectoryError(`${resolvedPath} is a directory`, path, pathParts, resolvedPath); } // fd cannot be used yet with opendir/readdir await this.earlyClose(fd, resolvedPath); return { attachedData: { pathParts, resolvedPath, fd, stats, }, fileName: `${pathParts.length > 1 ? pathParts[pathParts.length - 1] : '_'}.html`, mtimeMs: undefined, size: undefined, vary: undefined, contentEncoding: undefined, mimeType: 'text/html', mimeTypeCharset: 'UTF-8', lastModified: undefined, etag: undefined, cacheControl: undefined, contentDispositionType: undefined, contentDispositionFilename: undefined, }; } else if (haveTrailingSlash) { throw new file_system_errors_1.TrailingSlashError(`${String(path)} have a trailing slash but is not a directory`, path, [...pathParts, ''], pathParts); } } } catch (err) { if (fd !== undefined) { await this.earlyClose(fd, resolvedPath); } throw err; } return { attachedData: { pathParts, resolvedPath, fd, stats, }, fileName: pathParts[pathParts.length - 1], mtimeMs: stats.mtimeMs, size: stats.size, vary, contentEncoding, mimeType: undefined, mimeTypeCharset: undefined, lastModified: undefined, etag: undefined, cacheControl: undefined, contentDispositionType: undefined, contentDispositionFilename: undefined, }; } /** * Async generator method to return the directory listing as HTML * @param storageInfo - storage information * @yields html parts */ async *getDirectoryListing(storageInfo) { const { attachedData: { pathParts } } = storageInfo; const isNotRoot = pathParts.length > 1; const displayName = isNotRoot ? escapeHTMLInPath(pathParts[pathParts.length - 1]) : '/'; const display = `${isNotRoot ? escapeHTMLInPath(pathParts.join('/')) : ''}/`; yield `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>${displayName}</title><meta name="viewport" content="width=device-width"><meta name="description" content="Content of ${display} directory"></head><body><h1>Directory: ${display}</h1><ul>${isNotRoot ? '<li><a href="..">..</a></li>' : ''}`; const { ignorePattern } = this; const files = await this.opendir(storageInfo); for await (const file of files) { const { name: filename } = file; if (exports.FORBIDDEN_CHARACTERS.test(filename) || (ignorePattern && ignorePattern.test(filename))) { continue; } const escapedFilename = `${escapeHTMLInPath(filename)}${file.isDirectory() ? '/' : ''}`; yield `<li><a href="./${escapedFilename}">${escapedFilename}</a></li>`; } yield '</ul></body></html>'; } /** * Returns the list of files from a directory * @param storageInfo - storage information * @returns the list of files */ async opendir(storageInfo) { return this.fsOpendir ? this.fsOpendir(storageInfo.attachedData.resolvedPath) : { [Symbol.asyncIterator]: async function* asyncIterator() { const res = await this.fsReaddir(storageInfo.attachedData.resolvedPath, { withFileTypes: true }); for (const r of res) { yield r; } }.bind(this), }; } /** * Create readable stream from storage information * @param storageInfo - storage information * @param range - range to use or undefined if size is unknown * @param autoClose - true if stream should close itself * @returns readable stream */ createReadableStream(storageInfo, range, autoClose) { const { attachedData } = storageInfo; if (attachedData.stats.isDirectory()) { return node_stream_1.Readable.from(this.getDirectoryListing(storageInfo), { objectMode: false, encoding: 'utf-8', highWaterMark: 16384, autoDestroy: true }); } return this.fsCreateReadStream(attachedData.resolvedPath, range === undefined ? { fd: attachedData.fd, autoClose, } : { fd: attachedData.fd, autoClose, start: range.start, end: range.end, }); } /** * Close storage information * @param storageInfo - storage information * @returns void */ async close(storageInfo) { await this.fsClose(storageInfo.attachedData.fd); } } exports.GenericFileSystemStorage = GenericFileSystemStorage; class FileSystemStorage extends GenericFileSystemStorage { constructor(root, opts = {}) { super(root, { fsModule: { open: node_fs_1.open, fstat: node_fs_1.fstat, close: node_fs_1.close, createReadStream: node_fs_1.createReadStream, opendir: node_fs_1.opendir, readdir: node_fs_1.readdir, constants: node_fs_1.constants }, ...opts, }); } } exports.FileSystemStorage = FileSystemStorage; //# sourceMappingURL=file-system-storage.js.map