nm3clol-express-app/app/page/vercel-serve.mts

1016 lines
29 KiB
TypeScript

// Adapted from https://raw.githubusercontent.com/vercel/serve-handler/main/src/index.js
// Native
import path from 'path';
import { createHash } from 'crypto';
import { createReadStream, ReadStream, PathLike, EncodingOption, BufferEncodingOption, StatOptions, Stats, BigIntStats, ObjectEncodingOptions, Dirent, promises } from 'fs';
import { lstat, realpath, readlink, readdir } from 'fs/promises';
// Packages
import uri from 'fast-uri';
import slasher from './glob-slash.mjs';
import minimatch from 'minimatch';
import pathToRegexp from 'path-to-regexp';
import mime from 'mime-types';
import bytes from 'bytes';
import contentDisposition from 'content-disposition';
import isPathInside from 'path-is-inside';
import parseRange from 'range-parser';
import { ServerResponse, OutgoingHttpHeader, OutgoingHttpHeaders } from 'http';
import { Request } from 'express';
import ejs from 'ejs';
import { config } from '../config.mjs';
import helpers from '../helpers/functions.mjs';
import { Breadcrumb } from '../helpers/breadcrumbs.mjs';
export interface Dictionary<T> {
[Key: string]: T;
}
export interface StreamOptions {
flags?: string | undefined;
encoding?: BufferEncoding | undefined;
fd?: number | promises.FileHandle | undefined;
mode?: number | undefined;
autoClose?: boolean | undefined;
emitClose?: boolean | undefined;
start?: number | undefined;
signal?: AbortSignal | null | undefined;
highWaterMark?: number | undefined;
}
export interface FSImplementation {
open?: (...args: any[]) => any;
close?: (...args: any[]) => any;
}
export interface CreateReadStreamFSImplementation extends FSImplementation {
read: (...args: any[]) => any;
}
export interface ReadStreamOptions extends StreamOptions {
fs?: CreateReadStreamFSImplementation | null | undefined;
end?: number | undefined;
}
export type RegExpKey = pathToRegexp.Key;
export interface ServeRewrite {
source: string;
destination: string;
}
export interface ServeRedirect extends ServeRewrite {
type?: number;
}
export interface ServeHeader {
key: string;
value: string;
}
export interface ServeHeaderOverride {
source: string;
headers: ServeHeader[];
}
export interface ServeHandlerOptions {
/**
* By default, the current working directory will be served. If you only want to serve a specific path,
* you can use this options to pass an absolute path or a custom directory to be served relative to the
* current working directory.
*
* For example, if serving a Jekyll app, it would look like this:
* {
* "public": "_site"
* }
*
* Using absolute path:
*
* {
* "public": "/path/to/your/_site"
* }
*
* NOTE: The path cannot contain globs or regular expressions.
*/
public?: string;
/**
* By default, all .html files can be accessed without their extension.
*
* If one of these extensions is used at the end of a filename, it will automatically perform a redirect
* with status code 301 to the same path, but with the extension dropped.
*
* You can disable the feature like follows:
* {
* "cleanUrls": false
* }
*
* However, you can also restrict it to certain paths:
* {
* "cleanUrls": [
* "/app/**",
* "/!components/**"
* ]
* }
*
* NOTE: The paths can only contain globs that are matched using minimatch.
*/
cleanUrls?: boolean|string[];
/**
* If you want your visitors to receive a response under a certain path, but actually serve a completely
* different one behind the curtains, this option is what you need.
*
* It's perfect for single page applications (SPAs), for example:
* {
* "rewrites": [
* { "source": "app/**", "destination": "/index.html" },
* { "source": "projects/edit", "destination": "/edit-project.html" }
* ]
* }
*
* You can also use so-called "routing segments" as follows:
* {
* "rewrites": [
* { "source": "/projects/:id/edit", "destination": "/edit-project-:id.html" },
* ]
* }
*
* Now, if a visitor accesses /projects/123/edit, it will respond with the file /edit-project-123.html.
*
* NOTE: The paths can contain globs (matched using minimatch) or regular expressions (match using path-to-regexp).
*/
rewrites?: ServeRewrite[];
redirects?: ServeRedirect[];
headers?: ServeHeaderOverride[];
directoryListing?: boolean|string[];
unlisted?: string[];
trailingSlash?: boolean;
renderSingle?: boolean;
symlinks?: boolean;
etag?: boolean;
}
export interface Path {
name: string;
url: string;
}
export interface ServeDirectoryTemplateParameters {
files: PathDetails[];
directory: string;
paths: Path[];
};
export interface ServeErrorTemplateParameters {
statusCode: number;
code: string;
message: string;
err?: any;
}
export const directoryTemplate = (vals: ServeDirectoryTemplateParameters) => {
let breadcrumbs: Breadcrumb[] = [];
if (vals.paths.length == 1 && helpers.getDirectoryName(vals.paths[0].name)) {
breadcrumbs.push({ title: config.siteWelcomeMessage, url: '/' });
}
else {
vals.paths.forEach((path, index, paths) => {
if (index == 0) {
breadcrumbs.push({ title: config.siteName, url: '/' });
}
else {
breadcrumbs.push({ title: helpers.getDirectoryName(path.name).replaceAll('_', ' '), url: `/${path.url}` });
}
});
}
return new Promise((resolve, reject) => {
ejs.renderFile(path.join(config.viewsPath, 'directory.ejs'), { breadcrumbs, h: helpers, ...vals }, (err, str) => {
if (err) {
reject(err);
} else {
resolve(str);
}
});
});
};
export const errorTemplate = (vals: ServeErrorTemplateParameters) => {
return new Promise((resolve, reject) => {
ejs.renderFile(path.join(config.viewsPath, 'error.ejs'), { h: helpers, ...vals }, (err, str) => {
if (err) {
reject(err);
} else {
resolve(str);
}
});
});
};
export const isDirectoryOrDirectorySymbolicLink: (path: string, max_recursion_depth: number) => Promise<boolean> = async (path: string, max_recursion_depth = 10) => {
try {
let sym_stats = await lstat(path);
if (sym_stats.isSymbolicLink() && max_recursion_depth > 0) {
path = await readlink(path);
return isDirectoryOrDirectorySymbolicLink(path, max_recursion_depth-1);
}
return sym_stats.isDirectory();
} catch (err) {
throw err;
}
};
export const etags = new Map();
export const calculateSha = (handlers: ServeIOHandlers, absolutePath: string) =>
new Promise((resolve, reject) => {
const hash = createHash('sha1');
hash.update(path.extname(absolutePath));
hash.update('-');
const rs = handlers.createReadStream(absolutePath);
rs.on('error', reject);
rs.on('data', buf => hash.update(buf));
rs.on('end', () => {
const sha = hash.digest('hex');
resolve(sha);
});
});
export const sourceMatches = (source: string, requestPath: string, allowSegments: boolean = false) => {
const keys: RegExpKey[] = [];
const slashed = slasher(source);
const resolvedPath = path.posix.resolve(requestPath);
let results = null;
if (allowSegments) {
const normalized = slashed.replace('*', '(.*)');
const expression = pathToRegexp(normalized, keys);
results = expression.exec(resolvedPath);
if (!results) {
// clear keys so that they are not used
// later with empty results. this may
// happen if minimatch returns true
keys.length = 0;
}
}
if (results || minimatch(resolvedPath, slashed)) {
return {
keys,
results
};
}
return null;
};
const toTarget = (source: string, destination: string, previousPath: string) => {
const matches = sourceMatches(source, previousPath, true);
if (!matches) {
return null;
}
const {keys, results} = matches;
const props: Dictionary<any> = {};
const {scheme} = uri.parse(destination);
const normalizedDest = scheme ? destination : slasher(destination);
const toPath = pathToRegexp.compile(normalizedDest);
for (let index = 0; index < keys.length; index++) {
const {name} = keys[index];
props[name] = results && results.length ? results[index + 1] : undefined;
}
return toPath(props);
};
export const applyRewrites: (requestPath: string, rewrites?: ServeRewrite[], repetitive?: boolean) => string|null = (requestPath, rewrites = [], repetitive = false) => {
// We need to copy the array, since we're going to modify it.
const rewritesCopy = rewrites.slice();
// If the method was called again, the path was already rewritten
// so we need to make sure to return it.
const fallback = repetitive ? requestPath : null;
if (rewritesCopy.length === 0) {
return fallback;
}
for (let index = 0; index < rewritesCopy.length; index++) {
const {source, destination} = rewrites[index];
const target = toTarget(source, destination, requestPath);
if (target) {
// Remove rules that were already applied
rewritesCopy.splice(index, 1);
// Check if there are remaining ones to be applied
return applyRewrites(slasher(target), rewritesCopy, true);
}
}
return fallback;
};
export const ensureSlashStart = (target: string) => (target.startsWith('/') ? target : `/${target}`);
export const shouldRedirect = (decodedPath: string, { redirects, trailingSlash }: ServeHandlerOptions, cleanUrl: boolean) => {
const slashing = typeof trailingSlash === 'boolean';
const defaultType = 301;
const matchHTML = /(\.html|\/index)$/g;
if (redirects && redirects.length === 0 && !slashing && !cleanUrl) {
return null;
}
// By stripping the HTML parts from the decoded
// path *before* handling the trailing slash, we make
// sure that only *one* redirect occurs if both
// config options are used.
if (cleanUrl && matchHTML.test(decodedPath)) {
decodedPath = decodedPath.replace(matchHTML, '');
if (decodedPath.indexOf('//') > -1) {
decodedPath = decodedPath.replace(/\/+/g, '/');
}
return {
target: ensureSlashStart(decodedPath),
statusCode: defaultType
};
}
if (slashing) {
const {ext, name} = path.parse(decodedPath);
const isTrailed = decodedPath.endsWith('/');
const isDotfile = name.startsWith('.');
let target = null;
if (!trailingSlash && isTrailed) {
target = decodedPath.slice(0, -1);
} else if (trailingSlash && !isTrailed && !ext && !isDotfile) {
target = `${decodedPath}/`;
}
if (decodedPath.indexOf('//') > -1) {
target = decodedPath.replace(/\/+/g, '/');
}
if (target) {
return {
target: ensureSlashStart(target),
statusCode: defaultType
};
}
}
// This is currently the fastest way to
// iterate over an array
if (redirects) {
for (let index = 0; index < redirects.length; index++) {
const {source, destination, type} = redirects[index];
const target = toTarget(source, destination, decodedPath);
if (target) {
return {
target,
statusCode: type || defaultType
};
}
}
}
return null;
};
export interface SourceHeader {
key: string;
value: string;
}
export const appendHeaders = (target: OutgoingHttpHeaders, source: SourceHeader[]) => {
for (let index = 0; index < source.length; index++) {
const {key, value} = source[index];
target[key] = value;
}
};
export const getHeaders = async (handlers: ServeIOHandlers, config: ServeHandlerOptions, current: string, absolutePath: string, stats: Stats|BigIntStats|null) => {
const {headers: customHeaders = [], etag = false} = config;
const related = {};
const {base} = path.parse(absolutePath);
const relativePath = path.relative(current, absolutePath);
if (customHeaders.length > 0) {
// By iterating over all headers and never stopping, developers
// can specify multiple header sources in the config that
// might match a single path.
for (let index = 0; index < customHeaders.length; index++) {
const {source, headers} = customHeaders[index];
if (sourceMatches(source, slasher(relativePath))) {
appendHeaders(related, headers);
}
}
}
let defaultHeaders: Dictionary<string> = {};
if (stats) {
defaultHeaders = {
'Content-Length': new String(stats.size) as string,
// Default to "inline", which always tries to render in the browser,
// if that's not working, it will save the file. But to be clear: This
// only happens if it cannot find a appropiate value.
'Content-Disposition': contentDisposition(base, {
type: 'inline'
}),
'Accept-Ranges': 'bytes'
};
if (etag) {
let [mtime, sha] = etags.get(absolutePath) || [];
if (Number(mtime) !== Number(stats.mtime)) {
sha = await calculateSha(handlers, absolutePath);
etags.set(absolutePath, [stats.mtime, sha]);
}
defaultHeaders['ETag'] = `"${sha}"`;
} else {
defaultHeaders['Last-Modified'] = stats.mtime.toUTCString();
}
const contentType = mime.contentType(base);
if (contentType) {
defaultHeaders['Content-Type'] = contentType;
}
}
const headers: OutgoingHttpHeaders = Object.assign(defaultHeaders, related);
for (const key in headers) {
if (headers.hasOwnProperty(key) && headers[key] === null) {
delete headers[key];
}
}
return headers;
};
export const applicable = (decodedPath: string, configEntry?: boolean|string[]) => {
if (typeof configEntry === 'boolean') {
return configEntry;
}
if (Array.isArray(configEntry)) {
for (let index = 0; index < configEntry.length; index++) {
const source = configEntry[index];
if (sourceMatches(source, decodedPath)) {
return true;
}
}
return false;
}
return true;
};
export const getPossiblePaths = (relativePath: string, extension: string) => [
path.join(relativePath, `index${extension}`),
relativePath.endsWith('/') ? relativePath.replace(/\/$/g, extension) : (relativePath + extension)
].filter(item => path.basename(item) !== extension);
export const findRelated = async (current: string, relativePath: string, rewrittenPath: string|null, originalStat: lstat_signature) => {
const possible = rewrittenPath ? [rewrittenPath] : getPossiblePaths(relativePath, '.html');
let stats = null;
for (let index = 0; index < possible.length; index++) {
const related = possible[index];
const absolutePath = path.join(current, related);
try {
stats = await originalStat(absolutePath);
} catch (err) {
if (err && (err as {code: string}).code) {
if ((err as {code: string}).code !== 'ENOENT' && (err as {code: string}).code !== 'ENOTDIR') {
throw err;
}
}
}
if (stats) {
return {
stats,
absolutePath
};
}
}
return null;
};
export const canBeListed = (excluded: string[], file: string) => {
const slashed = slasher(file);
let whether = true;
for (let mark = 0; mark < excluded.length; mark++) {
const source = excluded[mark];
if (sourceMatches(source, slashed)) {
whether = false;
break;
}
}
return whether;
};
export interface PathDetails extends path.ParsedPath {
relative?: string;
type?: string;
size?: string;
title?: string;
}
export interface RenderDirectoryPathConfig {
relativePath: string;
absolutePath: string;
}
export const renderDirectory = async (current: string, acceptsJSON: boolean|null, handlers: ServeIOHandlers, methods: ServeIOHandlers|Dictionary<Function>, config: ServeHandlerOptions, paths: RenderDirectoryPathConfig) => {
const {directoryListing, trailingSlash, unlisted = [], renderSingle} = config;
const slashSuffix = typeof trailingSlash === 'boolean' ? (trailingSlash ? '/' : '') : '/';
const {relativePath, absolutePath} = paths;
const excluded = [
'.DS_Store',
'.git',
...unlisted
];
if (!applicable(relativePath, directoryListing) && !renderSingle) {
return {};
}
const filesAsStrings: string[]|Buffer[]|Dirent[] = await handlers.readdir(absolutePath);
let filesAsPathDetails: PathDetails[] = []
const canRenderSingle = renderSingle && (filesAsStrings.length === 1);
for (let index = 0; index < filesAsStrings.length; index++) {
const file = filesAsStrings[index] as string;
const filePath = path.resolve(absolutePath, file);
const details: PathDetails = path.parse(filePath);
// It's important to indicate that the `stat` call was
// spawned by the directory listing, as Now is
// simulating those calls and needs to special-case this.
let stats = null;
if (methods.lstat !== undefined) {
stats = await handlers.lstat(filePath, { bigint: true });
} else {
stats = await handlers.lstat(filePath, { bigint: false });
}
details.relative = path.join(relativePath, details.base);
//if (stats.isDirectory()) {
if (await handlers.isDirectoryOrDirectorySymbolicLink(filePath, 10)) {
details.base += slashSuffix;
details.relative += slashSuffix;
details.type = 'directory';
} else {
if (canRenderSingle) {
return {
singleFile: true,
absolutePath: filePath,
stats
};
}
details.ext = details.ext.split('.')[1] || 'txt';
details.type = 'file';
//TODO: this might not work with stats.size as bigint
details.size = bytes(stats.size as number, {
unitSeparator: ' ',
decimalPlaces: 0
});
}
details.title = details.base;
if (canBeListed(excluded, file)) {
filesAsPathDetails[index] = details;
} else {
delete filesAsStrings[index];
}
}
const toRoot = path.relative(current, absolutePath);
const directory = path.join(path.basename(current), toRoot, slashSuffix);
const pathParts = directory.split(path.sep).filter(Boolean);
// Sort to list directories first, then sort alphabetically
filesAsPathDetails = filesAsPathDetails.sort((a, b) => {
const aIsDir = a.type === 'directory';
const bIsDir = b.type === 'directory';
/* istanbul ignore next */
if (aIsDir && !bIsDir) {
return -1;
}
if ((bIsDir && !aIsDir) || (a.base > b.base)) {
return 1;
}
/* istanbul ignore next */
if (a.base < b.base) {
return -1;
}
/* istanbul ignore next */
return 0;
}).filter(Boolean);
// Add parent directory to the head of the sorted files array
if (toRoot.length > 0) {
const directoryPath = [...pathParts].slice(1);
const relative = path.join('/', ...directoryPath, '..', slashSuffix);
let parentDirPathDetails = path.parse(path.join('/', ...directoryPath, '..', slashSuffix));
filesAsPathDetails.unshift({
...parentDirPathDetails,
type: 'directory',
base: '..',
relative,
title: '..',
ext: '',
root: parentDirPathDetails.root,
dir: parentDirPathDetails.dir,
name: parentDirPathDetails.name,
});
}
const subPaths: Path[] = [];
for (let index = 0; index < pathParts.length; index++) {
const parents = [];
const isLast = index === (pathParts.length - 1);
let before = 0;
while (before <= index) {
parents.push(pathParts[before]);
before++;
}
parents.shift();
subPaths.push({
name: pathParts[index] + (isLast ? slashSuffix : '/'),
url: index === 0 ? '' : parents.join('/') + slashSuffix
});
}
const spec = {
files: filesAsPathDetails,
directory,
paths: subPaths
};
const output = acceptsJSON ? JSON.stringify(spec) : await directoryTemplate(spec);
return {directory: output};
};
export const sendError = async (absolutePath: string, response: ServerResponse, acceptsJSON: boolean|null, current: string, handlers: ServeIOHandlers, config: ServeHandlerOptions, spec: ServeErrorTemplateParameters) => {
const {err: original, message, code, statusCode} = spec;
/* istanbul ignore next */
if (original && process.env.NODE_ENV !== 'test') {
console.error(original);
}
response.statusCode = statusCode;
if (acceptsJSON) {
response.setHeader('Content-Type', 'application/json; charset=utf-8');
response.end(JSON.stringify({
error: spec
}));
return;
}
let stats = null;
const errorPage = path.join(current, `${statusCode}.html`);
try {
stats = await handlers.lstat(errorPage);
} catch (err) {
if (err && (err as {code: string}).code) {
if ((err as {code: string}).code !== 'ENOENT') {
console.error(err);
}
}
}
if (stats) {
let stream = null;
try {
stream = handlers.createReadStream(errorPage);
const headers = await getHeaders(handlers, config, current, errorPage, stats);
response.writeHead(statusCode, headers);
stream.pipe(response);
return;
} catch (err) {
console.error(err);
}
}
const headers = await getHeaders(handlers, config, current, absolutePath, null);
headers['Content-Type'] = 'text/html; charset=utf-8';
response.writeHead(statusCode, headers);
response.end(await errorTemplate(spec));
};
export const internalError = async (absolutePath: string, response: ServerResponse, acceptsJSON: boolean|null, current: string, handlers: ServeIOHandlers, config: ServeHandlerOptions, spec: any) => {
let internalError: ServeErrorTemplateParameters = {
statusCode: 500,
code: 'internal_server_error',
message: 'A server error has occurred',
err: spec,
};
return sendError(absolutePath, response, acceptsJSON, current, handlers, config, internalError);
};
export type lstat_signature =
((path: PathLike, opts?: StatOptions) => Promise<Stats | BigIntStats>);
export type realpath_signature =
((path: PathLike, options?: EncodingOption) => Promise<string>) |
((path: PathLike, options?: BufferEncodingOption) => Promise<Buffer>) |
((path: PathLike, options?: EncodingOption) => Promise<string | Buffer>);
export type createReadStream_signature = ((path: PathLike, options?: BufferEncoding | ReadStreamOptions | undefined) => ReadStream);
export type readdir_signature =
((path: PathLike, options?: | (ObjectEncodingOptions & { withFileTypes?: false | undefined; recursive?: boolean | undefined; }) | BufferEncoding | null,) => Promise<string[]>) |
((path: PathLike, options?: | { encoding: "buffer"; withFileTypes?: false | undefined; recursive?: boolean | undefined; } | "buffer",) => Promise<Buffer[]>) |
((path: PathLike, options?: | (ObjectEncodingOptions & { withFileTypes?: false | undefined; recursive?: boolean | undefined; }) | BufferEncoding | null,) => Promise<string[] | Buffer[]>) |
((path: PathLike, options?: ObjectEncodingOptions & { withFileTypes: true; recursive?: boolean | undefined;},) => Promise<Dirent[]>);
export type sendError_signature = ((absolutePath: string, response: ServerResponse, acceptsJSON: boolean|null, current: string, handlers: ServeIOHandlers, config: ServeHandlerOptions, spec: ServeErrorTemplateParameters) => void);
export type isDirectoryOrDirectorySymbolicLink_signature = ((path: string, max_recursion_depth: number) => Promise<boolean>);
export interface ServeIOHandlers {
lstat: lstat_signature,
realpath: realpath_signature,
createReadStream: createReadStream_signature,
readdir: readdir_signature,
sendError: sendError_signature,
isDirectoryOrDirectorySymbolicLink: isDirectoryOrDirectorySymbolicLink_signature,
}
lstat
export const getHandlers: (methods: ServeIOHandlers|Dictionary<Function>) => ServeIOHandlers = (methods) => Object.assign({
lstat,
realpath,
createReadStream,
readdir,
sendError,
isDirectoryOrDirectorySymbolicLink,
}, methods);
export default async (request: Request, response: ServerResponse, serveConfig: ServeHandlerOptions = {}, methods: ServeIOHandlers|Dictionary<Function> = {}) => {
const cwd = process.cwd();
const current = serveConfig.public ? path.resolve(cwd, serveConfig.public) : cwd;
const handlers = getHandlers(methods);
let relativePath = null;
let acceptsJSON = null;
if (request.headers!.accept) {
acceptsJSON = request.headers!.accept.includes('application/json');
}
try {
relativePath = decodeURIComponent(uri.parse(request.url).path as string);
} catch (err) {
return sendError('/', response, acceptsJSON, current, handlers, serveConfig, {
statusCode: 400,
code: 'bad_request',
message: 'Bad Request'
});
}
let absolutePath = path.join(current, relativePath);
// Prevent path traversal vulnerabilities. We could do this
// by ourselves, but using the package covers all the edge cases.
if (!isPathInside(absolutePath, current)) {
return sendError(absolutePath, response, acceptsJSON, current, handlers, serveConfig, {
statusCode: 400,
code: 'bad_request',
message: 'Bad Request'
});
}
const cleanUrl = applicable(relativePath, serveConfig.cleanUrls);
const redirect = shouldRedirect(relativePath, serveConfig, cleanUrl);
if (redirect) {
response.writeHead(redirect.statusCode, {
Location: encodeURI(redirect.target)
});
response.end();
return;
}
let stats = null;
// It's extremely important that we're doing multiple stat calls. This one
// right here could technically be removed, but then the program
// would be slower. Because for directories, we always want to see if a related file
// exists and then (after that), fetch the directory itself if no
// related file was found. However (for files, of which most have extensions), we should
// always stat right away.
//
// When simulating a file system without directory indexes, calculating whether a
// directory exists requires loading all the file paths and then checking if
// one of them includes the path of the directory. As that's a very
// performance-expensive thing to do, we need to ensure it's not happening if not really necessary.
if (path.extname(relativePath) !== '') {
try {
stats = await handlers.lstat(absolutePath);
} catch (err) {
if (err && (err as {code: string}).code) {
if ((err as {code: string}).code !== 'ENOENT' && (err as {code: string}).code !== 'ENOTDIR') {
return internalError(absolutePath, response, acceptsJSON, current, handlers, serveConfig, err);
}
}
}
}
const rewrittenPath = applyRewrites(relativePath, serveConfig.rewrites);
if (!stats && (cleanUrl || rewrittenPath)) {
try {
const related = await findRelated(current, relativePath, rewrittenPath, handlers.lstat);
if (related) {
({stats, absolutePath} = related);
}
} catch (err) {
if (err && (err as {code: string}).code) {
if ((err as {code: string}).code !== 'ENOENT' && (err as {code: string}).code !== 'ENOTDIR') {
return internalError(absolutePath, response, acceptsJSON, current, handlers, serveConfig, err);
}
}
}
}
if (!stats) {
try {
stats = await handlers.lstat(absolutePath);
} catch (err) {
console.error(err);
if (err && (err as {code: string}).code) {
if ((err as {code: string}).code !== 'ENOENT' && (err as {code: string}).code !== 'ENOTDIR') {
return internalError(absolutePath, response, acceptsJSON, current, handlers, serveConfig, err);
}
}
}
}
//if (stats && stats.isDirectory()) {
if (stats && await handlers.isDirectoryOrDirectorySymbolicLink(absolutePath, 10)) {
let directory = null;
let singleFile = null;
try {
const related = await renderDirectory(current, acceptsJSON, handlers, methods, serveConfig, {
relativePath,
absolutePath
});
if (related.singleFile) {
({stats, absolutePath, singleFile} = related);
} else {
({directory} = related);
}
} catch (err) {
console.log('error trap', err);
if (err && (err as {code: string}).code) {
if ((err as {code: string}).code !== 'ENOENT') {
return internalError(absolutePath, response, acceptsJSON, current, handlers, serveConfig, err);
}
}
}
if (directory) {
const contentType = acceptsJSON ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8';
response.statusCode = 200;
response.setHeader('Content-Type', contentType);
response.end(directory);
return;
}
if (!singleFile) {
// The directory listing is disabled, so we want to
// render a 404 error.
stats = null;
}
}
const isSymLink = stats && stats.isSymbolicLink();
// There are two scenarios in which we want to reply with
// a 404 error: Either the path does not exist, or it is a
// symlink while the `symlinks` option is disabled (which it is by default).
if (!stats || (!serveConfig.symlinks && isSymLink)) {
// allow for custom 404 handling
return handlers.sendError(absolutePath, response, acceptsJSON, current, handlers, serveConfig, {
statusCode: 404,
code: 'not_found',
message: 'The requested path could not be found'
});
}
// If we figured out that the target is a symlink, we need to
// resolve the symlink and run a new `stat` call just for the
// target of that symlink.
if (isSymLink) {
absolutePath = await handlers.realpath(absolutePath) as string;
stats = await handlers.lstat(absolutePath);
}
const streamOpts: ReadStreamOptions = {};
// TODO ? if-range
if (request.headers?.range && stats.size) {
//TODO might not work with stats.size as bigint
const range = parseRange(stats.size as number, request.headers.range);
if (typeof range === 'object' && range.type === 'bytes') {
const {start, end} = range[0];
streamOpts.start = start;
streamOpts.end = end;
response.statusCode = 206;
} else {
response.statusCode = 416;
response.setHeader('Content-Range', `bytes */${stats.size}`);
}
}
// TODO ? multiple ranges
let stream = null;
try {
stream = handlers.createReadStream(absolutePath, streamOpts);
} catch (err) {
return internalError(absolutePath, response, acceptsJSON, current, handlers, serveConfig, err);
}
const headers: OutgoingHttpHeaders = await getHeaders(handlers, serveConfig, current, absolutePath, stats);
// eslint-disable-next-line no-undefined
if (streamOpts.start !== undefined && streamOpts.end !== undefined) {
headers['Content-Range'] = `bytes ${streamOpts.start}-${streamOpts.end}/${stats.size}`;
headers['Content-Length'] = streamOpts.end - streamOpts.start + 1;
}
// We need to check for `headers.ETag` being truthy first, otherwise it will
// match `undefined` being equal to `undefined`, which is true.
//
// Checking for `undefined` and `null` is also important, because `Range` can be `0`.
//
// eslint-disable-next-line no-eq-null
if (request.headers?.range == null && headers.ETag && headers.ETag === request.headers?.['if-none-match']) {
response.statusCode = 304;
response.end();
return;
}
response.writeHead(response.statusCode || 200, headers);
stream.pipe(response);
};