File-based response support & static server
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
- 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 listing
This commit is contained in:
@@ -10,8 +10,9 @@ export class HTTPError extends ErrorWithContext {
|
||||
constructor(
|
||||
public readonly status: HTTPStatus = 500,
|
||||
public readonly message: string = '',
|
||||
context?: {[key: string]: any},
|
||||
) {
|
||||
super('HTTP ERROR')
|
||||
super('HTTP ERROR', context)
|
||||
this.message = message || HTTPMessage[status]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {Request} from '../../lifecycle/Request'
|
||||
import {plaintext} from '../../response/StringResponseFactory'
|
||||
import {ResponseFactory} from '../../response/ResponseFactory'
|
||||
import {json} from '../../response/JSONResponseFactory'
|
||||
import {UniversalPath} from '../../../util'
|
||||
import {file} from '../../response/FileResponseFactory'
|
||||
|
||||
/**
|
||||
* Base class for HTTP kernel modules that apply some response from a route handler to the request.
|
||||
@@ -22,6 +24,8 @@ export abstract class AbstractResolvedRouteHandlerHTTPModule extends HTTPKernelM
|
||||
|
||||
if ( object instanceof ResponseFactory ) {
|
||||
await object.write(request)
|
||||
} else if ( object instanceof UniversalPath ) {
|
||||
await file(object).write(request)
|
||||
} else if ( typeof object !== 'undefined' ) {
|
||||
await json(object).write(request)
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {Request} from './Request'
|
||||
import {ErrorWithContext, HTTPStatus, BehaviorSubject} from '../../util'
|
||||
import {ServerResponse} from 'http'
|
||||
import {HTTPCookieJar} from '../kernel/HTTPCookieJar'
|
||||
import {Readable} from 'stream'
|
||||
|
||||
/**
|
||||
* Error thrown when the server tries to re-send headers after they have been sent once.
|
||||
@@ -47,7 +48,7 @@ export class Response {
|
||||
private isBlockingWriteback = false
|
||||
|
||||
/** The body contents that should be written to the response. */
|
||||
public body = ''
|
||||
public body: string | Buffer | Uint8Array | Readable = ''
|
||||
|
||||
/**
|
||||
* Behavior subject fired right before the response content is written.
|
||||
@@ -192,18 +193,29 @@ export class Response {
|
||||
* Write the headers and specified data to the client.
|
||||
* @param data
|
||||
*/
|
||||
public async write(data: unknown): Promise<void> {
|
||||
public async write(data: string | Buffer | Uint8Array | Readable): Promise<void> {
|
||||
return new Promise<void>((res, rej) => {
|
||||
if ( !this.sentHeaders ) {
|
||||
this.sendHeaders()
|
||||
}
|
||||
this.serverResponse.write(data, error => {
|
||||
if ( error ) {
|
||||
rej(error)
|
||||
} else {
|
||||
res()
|
||||
}
|
||||
})
|
||||
|
||||
if ( data instanceof Readable ) {
|
||||
data.pipe(this.serverResponse)
|
||||
.on('finish', () => {
|
||||
res()
|
||||
})
|
||||
.on('error', error => {
|
||||
rej(error)
|
||||
})
|
||||
} else {
|
||||
this.serverResponse.write(data, error => {
|
||||
if ( error ) {
|
||||
rej(error)
|
||||
} else {
|
||||
res()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -212,9 +224,14 @@ export class Response {
|
||||
*/
|
||||
public async send(): Promise<void> {
|
||||
await this.sending$.next(this)
|
||||
this.setHeader('Content-Length', String(this.body?.length ?? 0))
|
||||
|
||||
if ( !(this.body instanceof Readable) ) {
|
||||
this.setHeader('Content-Length', String(this.body?.length ?? 0))
|
||||
}
|
||||
|
||||
await this.write(this.body ?? '')
|
||||
this.end()
|
||||
|
||||
await this.sent$.next(this)
|
||||
}
|
||||
|
||||
|
||||
36
src/http/response/FileResponseFactory.ts
Normal file
36
src/http/response/FileResponseFactory.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
169
src/http/servers/static.ts
Normal file
169
src/http/servers/static.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export * from './http/response/ResponseFactory'
|
||||
export * from './http/response/StringResponseFactory'
|
||||
export * from './http/response/TemporaryRedirectResponseFactory'
|
||||
export * from './http/response/ViewResponseFactory'
|
||||
export * from './http/response/FileResponseFactory'
|
||||
|
||||
export * from './http/routing/ActivatedRoute'
|
||||
export * from './http/routing/Route'
|
||||
@@ -57,6 +58,8 @@ export * from './http/session/MemorySession'
|
||||
|
||||
export * from './http/Controller'
|
||||
|
||||
export * from './http/servers/static'
|
||||
|
||||
export * from './service/Canonical'
|
||||
export * from './service/CanonicalInstantiable'
|
||||
export * from './service/CanonicalRecursive'
|
||||
|
||||
45
src/resources/views/static/dirlist.pug
Normal file
45
src/resources/views/static/dirlist.pug
Normal file
@@ -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>
|
||||
@@ -50,6 +50,20 @@ class Collection<T> {
|
||||
return new Collection(items)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new collection from an item or array of items.
|
||||
* Filters out undefined items.
|
||||
* @param itemOrItems
|
||||
*/
|
||||
public static normalize<T2>(itemOrItems: (CollectionItem<T2> | undefined)[] | CollectionItem<T2> | undefined): Collection<T2> {
|
||||
if ( !Array.isArray(itemOrItems) ) {
|
||||
itemOrItems = [itemOrItems]
|
||||
}
|
||||
|
||||
const items = itemOrItems.filter(x => typeof x !== 'undefined') as CollectionItem<T2>[]
|
||||
return new Collection<T2>(items)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a collection of "undefined" elements of a given size.
|
||||
* @param size
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as nodePath from 'path'
|
||||
import * as fs from 'fs'
|
||||
import * as mkdirp from 'mkdirp'
|
||||
import { Filesystem } from './path/Filesystem'
|
||||
import ReadableStream = NodeJS.ReadableStream;
|
||||
import WritableStream = NodeJS.WritableStream;
|
||||
import * as mime from 'mime-types'
|
||||
import {FileNotFoundError, Filesystem} from './path/Filesystem'
|
||||
import {Collection} from '../collection/Collection'
|
||||
import {Readable, Writable} from 'stream'
|
||||
|
||||
/**
|
||||
* An item that could represent a path.
|
||||
@@ -22,6 +23,36 @@ export function universalPath(...parts: PathLike[]): UniversalPath {
|
||||
return main.concat(...concats)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes as human-readable text.
|
||||
*
|
||||
* @param bytes Number of bytes.
|
||||
* @param si True to use metric (SI) units, aka powers of 1000. False to use
|
||||
* binary (IEC), aka powers of 1024.
|
||||
* @param dp Number of decimal places to display.
|
||||
* @see https://stackoverflow.com/a/14919494/4971138
|
||||
*/
|
||||
export function bytesToHumanFileSize(bytes: number, si = false, dp = 1): string {
|
||||
const thresh = si ? 1000 : 1024
|
||||
|
||||
if (Math.abs(bytes) < thresh) {
|
||||
return bytes + ' B'
|
||||
}
|
||||
|
||||
const units = si
|
||||
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
|
||||
let u = -1
|
||||
const r = 10 ** dp
|
||||
|
||||
do {
|
||||
bytes /= thresh
|
||||
++u
|
||||
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1)
|
||||
|
||||
return bytes.toFixed(dp) + ' ' + units[u]
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk recursively over entries in a directory.
|
||||
*
|
||||
@@ -155,6 +186,13 @@ export class UniversalPath {
|
||||
return `${this.prefix}${this.resourceLocalPath}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the basename of the path.
|
||||
*/
|
||||
get toBase(): string {
|
||||
return nodePath.basename(this.resourceLocalPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append and resolve the given paths to this resource and return a new UniversalPath.
|
||||
*
|
||||
@@ -224,6 +262,44 @@ export class UniversalPath {
|
||||
return walk(this.resourceLocalPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves true if this resource is a directory.
|
||||
*/
|
||||
async isDirectory(): Promise<boolean> {
|
||||
if ( this.filesystem ) {
|
||||
const stat = await this.filesystem.stat({
|
||||
storePath: this.resourceLocalPath,
|
||||
})
|
||||
|
||||
return stat.isDirectory
|
||||
}
|
||||
|
||||
try {
|
||||
return (await fs.promises.stat(this.resourceLocalPath)).isDirectory()
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves true if this resource is a regular file.
|
||||
*/
|
||||
async isFile(): Promise<boolean> {
|
||||
if ( this.filesystem ) {
|
||||
const stat = await this.filesystem.stat({
|
||||
storePath: this.resourceLocalPath,
|
||||
})
|
||||
|
||||
return stat.isFile
|
||||
}
|
||||
|
||||
try {
|
||||
return (await fs.promises.stat(this.resourceLocalPath)).isFile()
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given resource exists at the path.
|
||||
*/
|
||||
@@ -244,6 +320,20 @@ export class UniversalPath {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List any immediate children of this resource.
|
||||
*/
|
||||
async list(): Promise<Collection<UniversalPath>> {
|
||||
if ( this.filesystem ) {
|
||||
const files = await this.filesystem.list(this.resourceLocalPath)
|
||||
return files.map(x => this.concat(x))
|
||||
}
|
||||
|
||||
const paths = await fs.promises.readdir(this.resourceLocalPath)
|
||||
return Collection.collect<string>(paths)
|
||||
.map(x => this.concat(x))
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively create this path as a directory. Equivalent to `mkdir -p` on Linux.
|
||||
*/
|
||||
@@ -290,7 +380,7 @@ export class UniversalPath {
|
||||
/**
|
||||
* Get a writable stream to this file's contents.
|
||||
*/
|
||||
async writeStream(): Promise<WritableStream> {
|
||||
async writeStream(): Promise<Writable> {
|
||||
if ( this.filesystem ) {
|
||||
return this.filesystem.putStoreFileAsStream({
|
||||
storePath: this.resourceLocalPath,
|
||||
@@ -304,7 +394,7 @@ export class UniversalPath {
|
||||
* Read the data from this resource's file as a string.
|
||||
*/
|
||||
async read(): Promise<string> {
|
||||
let stream: ReadableStream
|
||||
let stream: Readable
|
||||
if ( this.filesystem ) {
|
||||
stream = await this.filesystem.getStoreFileAsStream({
|
||||
storePath: this.resourceLocalPath,
|
||||
@@ -321,10 +411,37 @@ export class UniversalPath {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of this resource in bytes.
|
||||
*/
|
||||
async sizeInBytes(): Promise<number> {
|
||||
if ( this.filesystem ) {
|
||||
const stat = await this.filesystem.stat({
|
||||
storePath: this.resourceLocalPath,
|
||||
})
|
||||
|
||||
if ( stat.exists ) {
|
||||
return stat.sizeInBytes
|
||||
}
|
||||
|
||||
throw new FileNotFoundError(this.toString())
|
||||
}
|
||||
|
||||
const stat = await fs.promises.stat(this.resourceLocalPath)
|
||||
return stat.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of this resource, formatted in a human-readable string.
|
||||
*/
|
||||
async sizeForHumans(): Promise<string> {
|
||||
return bytesToHumanFileSize(await this.sizeInBytes())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a readable stream of this file's contents.
|
||||
*/
|
||||
async readStream(): Promise<ReadableStream> {
|
||||
async readStream(): Promise<Readable> {
|
||||
if ( this.filesystem ) {
|
||||
return this.filesystem.getStoreFileAsStream({
|
||||
storePath: this.resourceLocalPath,
|
||||
@@ -334,17 +451,70 @@ export class UniversalPath {
|
||||
}
|
||||
}
|
||||
|
||||
/* get mime_type() {
|
||||
return Mime.lookup(this.ext)
|
||||
}
|
||||
|
||||
get content_type() {
|
||||
return Mime.contentType(this.ext)
|
||||
}
|
||||
|
||||
get charset() {
|
||||
if ( this.mime_type ) {
|
||||
return Mime.charset(this.mime_type)
|
||||
/**
|
||||
* Returns true if this path exists in the subtree of the given path.
|
||||
* @param otherPath
|
||||
*/
|
||||
isChildOf(otherPath: UniversalPath): boolean {
|
||||
if ( (this.filesystem || otherPath.filesystem) && otherPath.filesystem !== this.filesystem ) {
|
||||
return false
|
||||
}
|
||||
}*/
|
||||
|
||||
if ( this.prefix !== otherPath.prefix ) {
|
||||
return false
|
||||
}
|
||||
|
||||
const relative = nodePath.relative(otherPath.toLocal, this.toLocal)
|
||||
return Boolean(relative && !relative.startsWith('..') && !nodePath.isAbsolute(relative))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given path exists in the subtree of this path.
|
||||
* @param otherPath
|
||||
*/
|
||||
isParentOf(otherPath: UniversalPath): boolean {
|
||||
return otherPath.isChildOf(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given path refers to the same resource as this path.
|
||||
* @param otherPath
|
||||
*/
|
||||
is(otherPath: UniversalPath): boolean {
|
||||
if ( (this.filesystem || otherPath.filesystem) && otherPath.filesystem !== this.filesystem ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ( this.prefix !== otherPath.prefix ) {
|
||||
return false
|
||||
}
|
||||
|
||||
const relative = nodePath.relative(otherPath.toLocal, this.toLocal)
|
||||
return relative === ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mime-type of this resource.
|
||||
*/
|
||||
get mimeType(): string | false {
|
||||
return mime.lookup(this.resourceLocalPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content-type header of this resource.
|
||||
*/
|
||||
get contentType(): string | false {
|
||||
return mime.contentType(this.resourceLocalPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the charset of this resource.
|
||||
*/
|
||||
get charset(): string | false {
|
||||
if ( this.mimeType ) {
|
||||
return mime.charset(this.mimeType)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import {UniversalPath} from '../path'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import {uuid4} from '../data'
|
||||
import ReadableStream = NodeJS.ReadableStream;
|
||||
import WritableStream = NodeJS.WritableStream;
|
||||
import {ErrorWithContext} from '../../error/ErrorWithContext'
|
||||
import {Readable, Writable} from 'stream'
|
||||
import {Awaitable} from '../types'
|
||||
import {Collection} from '../../collection/Collection'
|
||||
|
||||
/**
|
||||
* Error thrown when an operation is attempted on a non-existent file.
|
||||
@@ -65,6 +66,16 @@ export interface Stat {
|
||||
*/
|
||||
tags: string[],
|
||||
|
||||
/**
|
||||
* True if the resource exists as a directory.
|
||||
*/
|
||||
isDirectory: boolean,
|
||||
|
||||
/**
|
||||
* True if the resource exists as a regular file.
|
||||
*/
|
||||
isFile: boolean,
|
||||
|
||||
accessed?: Date,
|
||||
modified?: Date,
|
||||
created?: Date,
|
||||
@@ -77,12 +88,12 @@ export abstract class Filesystem {
|
||||
/**
|
||||
* Called when the Filesystem driver is initialized. Do any standup here.
|
||||
*/
|
||||
public open(): void | Promise<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
public open(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Called when the Filesystem driver is destroyed. Do any cleanup here.
|
||||
*/
|
||||
public close(): void | Promise<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
public close(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Get the URI prefix for this filesystem.
|
||||
@@ -114,62 +125,67 @@ export abstract class Filesystem {
|
||||
*
|
||||
* @param args
|
||||
*/
|
||||
public abstract putLocalFile(args: {localPath: string, storePath: string, mimeType?: string, tags?: string[], tag?: string}): void | Promise<void>
|
||||
public abstract putLocalFile(args: {localPath: string, storePath: string, mimeType?: string, tags?: string[], tag?: string}): Awaitable<void>
|
||||
|
||||
/**
|
||||
* Download a file in the remote filesystem to the local filesystem and return it as a UniversalPath.
|
||||
* @param args
|
||||
*/
|
||||
public abstract getStoreFileAsTemp(args: {storePath: string}): UniversalPath | Promise<UniversalPath>
|
||||
public abstract getStoreFileAsTemp(args: {storePath: string}): Awaitable<UniversalPath>
|
||||
|
||||
/**
|
||||
* Open a readable stream for a file in the remote filesystem.
|
||||
* @param args
|
||||
*/
|
||||
public abstract getStoreFileAsStream(args: {storePath: string}): ReadableStream | Promise<ReadableStream>
|
||||
public abstract getStoreFileAsStream(args: {storePath: string}): Awaitable<Readable>
|
||||
|
||||
/**
|
||||
* Open a writable stream for a file in the remote filesystem.
|
||||
* @param args
|
||||
*/
|
||||
public abstract putStoreFileAsStream(args: {storePath: string}): WritableStream | Promise<WritableStream>
|
||||
public abstract putStoreFileAsStream(args: {storePath: string}): Awaitable<Writable>
|
||||
|
||||
/**
|
||||
* Fetch some information about a file that may or may not be in the remote filesystem without fetching the entire file.
|
||||
* @param args
|
||||
*/
|
||||
public abstract stat(args: {storePath: string}): Stat | Promise<Stat>
|
||||
public abstract stat(args: {storePath: string}): Awaitable<Stat>
|
||||
|
||||
/**
|
||||
* If the file does not exist in the remote filesystem, create it. If it does exist, update the modify timestamps.
|
||||
* @param args
|
||||
*/
|
||||
public abstract touch(args: {storePath: string}): void | Promise<void>
|
||||
public abstract touch(args: {storePath: string}): Awaitable<void>
|
||||
|
||||
/**
|
||||
* Remove the given resource(s) from the remote filesystem.
|
||||
* @param args
|
||||
*/
|
||||
public abstract remove(args: {storePath: string, recursive?: boolean }): void | Promise<void>
|
||||
public abstract remove(args: {storePath: string, recursive?: boolean }): Awaitable<void>
|
||||
|
||||
/**
|
||||
* Create the given path on the store as a directory, recursively.
|
||||
* @param args
|
||||
*/
|
||||
public abstract mkdir(args: {storePath: string}): void | Promise<void>
|
||||
public abstract mkdir(args: {storePath: string}): Awaitable<void>
|
||||
|
||||
/**
|
||||
* Get the metadata object for the given file, if it exists.
|
||||
* @param storePath
|
||||
*/
|
||||
public abstract getMetadata(storePath: string): FileMetadata | Promise<FileMetadata>
|
||||
public abstract getMetadata(storePath: string): Awaitable<FileMetadata>
|
||||
|
||||
/**
|
||||
* Set the metadata object for the given file, if the file exists.
|
||||
* @param storePath
|
||||
* @param meta
|
||||
*/
|
||||
public abstract setMetadata(storePath: string, meta: FileMetadata): void | Promise<void>
|
||||
public abstract setMetadata(storePath: string, meta: FileMetadata): Awaitable<void>
|
||||
|
||||
/**
|
||||
* List direct children of this resource.
|
||||
*/
|
||||
public abstract list(storePath: string): Awaitable<Collection<string>>
|
||||
|
||||
/**
|
||||
* Normalize the input tags into a single array of strings. This is useful for implementing the fluent
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as path from 'path'
|
||||
import {UniversalPath} from '../path'
|
||||
import * as rimraf from 'rimraf'
|
||||
import * as mkdirp from 'mkdirp'
|
||||
import { Collection } from '../../collection/Collection'
|
||||
|
||||
export interface LocalFilesystemConfig {
|
||||
baseDir: string
|
||||
@@ -87,6 +88,8 @@ export class LocalFilesystem extends Filesystem {
|
||||
accessed: stat.atime,
|
||||
modified: stat.mtime,
|
||||
created: stat.ctime,
|
||||
isDirectory: stat.isDirectory(),
|
||||
isFile: stat.isFile(),
|
||||
}
|
||||
} catch (e) {
|
||||
if ( e?.code === 'ENOENT' ) {
|
||||
@@ -95,6 +98,8 @@ export class LocalFilesystem extends Filesystem {
|
||||
exists: false,
|
||||
sizeInBytes: 0,
|
||||
tags: [],
|
||||
isFile: false,
|
||||
isDirectory: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,4 +171,13 @@ export class LocalFilesystem extends Filesystem {
|
||||
protected metadataPath(storePath: string): string {
|
||||
return path.resolve(this.baseConfig.baseDir, 'meta', storePath + '.json')
|
||||
}
|
||||
|
||||
/**
|
||||
* List all immediate children of the given path.
|
||||
* @param storePath
|
||||
*/
|
||||
public async list(storePath: string): Promise<Collection<string>> {
|
||||
const paths = await fs.promises.readdir(this.storePath(storePath))
|
||||
return Collection.collect<string>(paths)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import {FileMetadata, Filesystem, Stat} from './Filesystem'
|
||||
import * as ssh2 from 'ssh2'
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs'
|
||||
import ReadableStream = NodeJS.ReadableStream
|
||||
import {Readable, Writable} from 'stream'
|
||||
import {Collection} from '../../collection/Collection'
|
||||
import {UniversalPath} from '../path'
|
||||
|
||||
/**
|
||||
@@ -37,7 +38,7 @@ export class SSHFilesystem extends Filesystem {
|
||||
})
|
||||
}
|
||||
|
||||
async getStoreFileAsStream(args: { storePath: string }): Promise<ReadableStream> {
|
||||
async getStoreFileAsStream(args: { storePath: string }): Promise<Readable> {
|
||||
const sftp = await this.getSFTP()
|
||||
return sftp.createReadStream(this.storePath(args.storePath))
|
||||
}
|
||||
@@ -62,7 +63,7 @@ export class SSHFilesystem extends Filesystem {
|
||||
})
|
||||
}
|
||||
|
||||
async putStoreFileAsStream(args: { storePath: string }): Promise<NodeJS.WritableStream> {
|
||||
async putStoreFileAsStream(args: { storePath: string }): Promise<Writable> {
|
||||
const sftp = await this.getSFTP()
|
||||
return sftp.createWriteStream(this.storePath(args.storePath))
|
||||
}
|
||||
@@ -126,6 +127,8 @@ export class SSHFilesystem extends Filesystem {
|
||||
accessed: stat.atime,
|
||||
modified: stat.mtime,
|
||||
created: stat.ctime,
|
||||
isFile: stat.isFile(),
|
||||
isDirectory: stat.isDirectory(),
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
@@ -133,6 +136,8 @@ export class SSHFilesystem extends Filesystem {
|
||||
exists: false,
|
||||
sizeInBytes: 0,
|
||||
tags: [],
|
||||
isFile: false,
|
||||
isDirectory: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,7 +248,7 @@ export class SSHFilesystem extends Filesystem {
|
||||
* @protected
|
||||
*/
|
||||
protected storePath(storePath: string): string {
|
||||
return path.resolve(this.baseConfig.baseDir, 'data', storePath)
|
||||
return path.join(this.baseConfig.baseDir, 'data', storePath)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,7 +257,7 @@ export class SSHFilesystem extends Filesystem {
|
||||
* @protected
|
||||
*/
|
||||
protected metadataPath(storePath: string): string {
|
||||
return path.resolve(this.baseConfig.baseDir, 'meta', storePath + '.json')
|
||||
return path.join(this.baseConfig.baseDir, 'meta', storePath + '.json')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,4 +273,18 @@ export class SSHFilesystem extends Filesystem {
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
||||
})
|
||||
}
|
||||
|
||||
async list(storePath: string): Promise<Collection<string>> {
|
||||
const sftp = await this.getSFTP()
|
||||
|
||||
return new Promise<Collection<string>>((res, rej) => {
|
||||
sftp.readdir(this.storePath(storePath), (error, files) => {
|
||||
if ( error ) {
|
||||
rej(error)
|
||||
} else {
|
||||
res(Collection.collect(files).map(x => x.filename))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user