import {Request} from '../lifecycle/Request' import {ActivatedRoute} from '../routing/ActivatedRoute' import {Config} from '../../service/Config' import {Collection, HTTPStatus, UniversalPath, universalPath} from '../../util' import {Application} from '../../lifecycle/Application' import {HTTPError} from '../HTTPError' import {view, ViewResponseFactory} from '../response/ViewResponseFactory' import {redirect} from '../response/RedirectResponseFactory' import {file} from '../response/FileResponseFactory' import {ResponseObject} from '../routing/Route' import {Logging} from '../../service/Logging' /** * Defines the behavior of the static server. */ export interface StaticServerOptions { /** If true, browsing to a directory route will show the directory listing page. */ directoryListing?: boolean /** The path to the directory whose files should be served. */ basePath?: string | string[] | UniversalPath /** If specified, only files with these extensions will be served. */ allowedExtensions?: string[] /** If specified, files with these extensions will not be served. */ excludedExtensions?: string[] /** If a file with this name exists in a directory, it will be served. */ indexFile?: string } /** * HTTPError class thrown by the static server. */ export class StaticServerHTTPError extends HTTPError { } /** * Build the response factory that shows the directory listing. * @param dirname * @param dirPath */ async function getDirectoryListingResponse(dirname: string, dirPath: UniversalPath): Promise { return view('@extollo:static:dirlist', { dirname, contents: (await (await dirPath.list()) .promiseMap(async path => { const isDirectory = await path.isDirectory() return { isDirectory, name: path.toBase, size: isDirectory ? '-' : await path.sizeForHumans(), } })) .sortBy(row => { return `${row.isDirectory ? 0 : 1}${row.name}` }) .all(), }) } /** * Returns true if the given file path has an extension that is allowed by * the static server options. * @param filePath * @param options */ function isValidFileExtension(filePath: UniversalPath, options: StaticServerOptions): boolean { return ( ( !options.allowedExtensions || options.allowedExtensions.includes(filePath.ext) ) && ( !options.excludedExtensions || !options.excludedExtensions.includes(filePath.ext) ) ) } /** * Resolve the configured base path into a universal path. * Defaults to `{app path}/resources/static` if none provided. * @param appPath * @param basePath */ function getBasePath(appPath: UniversalPath, basePath?: string | string[] | UniversalPath): UniversalPath { if ( basePath instanceof UniversalPath ) { return basePath } if ( !basePath ) { return appPath.concat('resources', 'static') } if ( Array.isArray(basePath) ) { return appPath.concat(...basePath) } if ( basePath.startsWith('/') ) { return universalPath(basePath) } return appPath.concat(basePath) } /** * Get a route handler that serves a directory as static files. * @param options */ export function staticServer(options: StaticServerOptions = {}): (request: Request) => Promise { return async (request: Request) => { const config = request.make(Config) const route = > request.make(ActivatedRoute) const app = request.make(Application) const logging = request.make(Logging) const staticConfig = config.get('server.builtIns.static', {}) const mergedOptions = { ...staticConfig, ...options, } // Resolve the path to the resource on the filesystem const basePath = getBasePath(app.appPath(), mergedOptions.basePath) const filePath = basePath.concat(...Collection.normalize(route.params[0])) // If the resolved path is outside of the base path, fail out if ( !filePath.isChildOf(basePath) && !filePath.is(basePath) ) { throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', { basePath: basePath.toString(), filePath: filePath.toString(), route: route.path, reason: 'Resolved file is not a child of the base path.', }) } // If the resolved file is an invalid file extension, fail out if ( !isValidFileExtension(filePath, mergedOptions) ) { throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', { basePath: basePath.toString(), filePath: filePath.toString(), route: route.path, allowedExtensions: mergedOptions.allowedExtensions, excludedExtensions: mergedOptions.excludedExtensions, reason: 'Resolved file is not an allowed extension type', }) } // If the resolved file does not exist on the filesystem, fail out if ( !(await filePath.exists()) ) { throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, `File not found: ${route.path}`, { basePath: basePath.toString(), filePath: filePath.toString(), route: route.path, reason: 'Resolved file does not exist on the filesystem', }) } // If the resolved path is a directory, send the directory listing response if ( await filePath.isDirectory() ) { if ( options.indexFile ) { const indexFile = filePath.concat(options.indexFile) if ( await indexFile.exists() ) { return file(indexFile) } } if ( !options.directoryListing ) { throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', { basePath: basePath.toString(), filePath: filePath.toString(), route: route.path, reason: 'Path is a directory, and directory listing is disabled', }) } if ( !route.path.endsWith('/') ) { return redirect(`${route.path}/`) } return getDirectoryListingResponse(route.path, filePath) } // Otherwise, just send the file as the response body logging.verbose(`Sending file: ${filePath}`) return file(filePath) } }