File-based response support & static server
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
- Clean up UniversalPath implementation - Use Readable/Writable types correctly for stream methods - Add .list() methods for getting child files - Make Response body specify explicit types and support writing Readable streams to the body - Create a static file server that supports directory listingorm-types
parent
b3b5b169e8
commit
f496046461
@ -0,0 +1,36 @@
|
||||
import {ResponseFactory} from './ResponseFactory'
|
||||
import {Request} from '../lifecycle/Request'
|
||||
import {ErrorWithContext, UniversalPath} from '../../util'
|
||||
|
||||
/**
|
||||
* Helper function that creates a FileResponseFactory for the given path.
|
||||
* @param path
|
||||
*/
|
||||
export function file(path: UniversalPath): FileResponseFactory {
|
||||
return new FileResponseFactory(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP response factory that sends a file referenced by a given UniversalPath.
|
||||
*/
|
||||
export class FileResponseFactory extends ResponseFactory {
|
||||
constructor(
|
||||
/** The file to be sent. */
|
||||
public readonly path: UniversalPath,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
public async write(request: Request): Promise<Request> {
|
||||
if ( !(await this.path.isFile()) ) {
|
||||
throw new ErrorWithContext(`Cannot write non-file resource as response: ${this.path}`, {
|
||||
path: this.path,
|
||||
})
|
||||
}
|
||||
|
||||
request.response.setHeader('Content-Type', this.path.contentType || 'application/octet-stream')
|
||||
request.response.setHeader('Content-Length', String(await this.path.sizeInBytes()))
|
||||
request.response.body = await this.path.readStream()
|
||||
return request
|
||||
}
|
||||
}
|
@ -0,0 +1,169 @@
|
||||
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/TemporaryRedirectResponseFactory'
|
||||
import {file} from '../response/FileResponseFactory'
|
||||
import {RouteHandler} from '../routing/Route'
|
||||
|
||||
/**
|
||||
* 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[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {}): RouteHandler {
|
||||
return async (request: Request) => {
|
||||
const config = <Config> request.make(Config)
|
||||
const route = <ActivatedRoute> request.make(ActivatedRoute)
|
||||
const app = <Application> request.make(Application)
|
||||
|
||||
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 ( !route.path.endsWith('/') ) {
|
||||
return redirect(`${route.path}/`)
|
||||
}
|
||||
|
||||
return getDirectoryListingResponse(route.path, filePath)
|
||||
}
|
||||
|
||||
// Otherwise, just send the file as the response body
|
||||
return file(filePath)
|
||||
}
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
doctype html
|
||||
html
|
||||
head
|
||||
title Index of #{dirname}
|
||||
style.
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td, th {
|
||||
border: 1px solid #dddddd;
|
||||
text-align: left;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: #dddddd;
|
||||
}
|
||||
body
|
||||
h1 Directory Listing
|
||||
h2 #{dirname}
|
||||
table
|
||||
tr
|
||||
th Name
|
||||
th Type
|
||||
th Size
|
||||
tr
|
||||
td 📂
|
||||
a(href='..') ..
|
||||
td Directory
|
||||
td -
|
||||
each entry in contents
|
||||
tr
|
||||
td #{entry.isDirectory ? '📂 ' : ''}
|
||||
a(href='./' + entry.name) #{entry.name}
|
||||
td #{entry.isDirectory ? 'Directory' : 'File'}
|
||||
td #{entry.size}
|
||||
if !config('server.poweredBy.hide', false)
|
||||
hr
|
||||
small retrieved at #{(new Date).toDateString()} #{(new Date).toTimeString()} | powered by <a href="https://extollo.garrettmills.dev/" target="_blank">Extollo</a>
|
Loading…
Reference in new issue