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
JavaScript
;
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, '&');
}
// 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