All checks were successful
continuous-integration/drone/push Build is passing
192 lines
6.7 KiB
TypeScript
192 lines
6.7 KiB
TypeScript
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)
|
|
}
|
|
}
|