You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
lib/src/http/servers/static.ts

192 lines
6.7 KiB

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<ViewResponseFactory> {
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<ResponseObject> {
return async (request: Request) => {
const config = <Config> request.make(Config)
const route = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute)
const app = <Application> request.make(Application)
const logging = <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<string>(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)
}
}