diff --git a/TODO.txt b/TODO.txt index ca36f0d..bac61c4 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,14 +1,12 @@ -static assets -middleware +TLS support internationalization uploads & universal path CLI - view routes, template generation, start server, directives, output, args -request level error handler -develop/prod/debug modes +nicer error and home pages favicon -utility - root, path, is_windows, is_linux, is_mac +utility - is_windows, is_linux, is_mac authentication - user/session, oauth, jwt, &c. -orm relations, subqueries, enum/bit fields, json handlers, scopes +orm enum/bit fields, json handlers, scopes redis - redis client, redis rehydrated classes, redis sessions less/scss notifications - gotify/push/other mechanisms diff --git a/app/assets/.gitignore b/app/assets/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/app/assets/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/app/bundle/daton_units.ts b/app/bundle/daton_units.ts index 2d624f5..47d00e8 100644 --- a/app/bundle/daton_units.ts +++ b/app/bundle/daton_units.ts @@ -9,3 +9,4 @@ export { default as HttpServerUnit } from '../../lib/src/unit/HttpServer.ts' export { default as RoutingUnit } from '../../lib/src/unit/Routing.ts' export { default as ServicesUnit } from '../../lib/src/unit/Services.ts' export { default as ViewEngineUnit } from '../../lib/src/unit/ViewEngine.ts' +export { default as DatonMiddlewareUnit } from '../../lib/src/unit/DatonMiddleware.ts' diff --git a/app/configs/server.config.ts b/app/configs/server.config.ts index 90f8ba1..3ad5062 100644 --- a/app/configs/server.config.ts +++ b/app/configs/server.config.ts @@ -14,5 +14,5 @@ export default { session: { driver: 'database', // memory | database model: 'http:Session', // required for database - } + }, } diff --git a/app/http/routes/home.routes.ts b/app/http/routes/home.routes.ts index 2aceaea..230c34e 100644 --- a/app/http/routes/home.routes.ts +++ b/app/http/routes/home.routes.ts @@ -6,5 +6,6 @@ export default { get: { '/': 'controller::Home.get_home', '/maybe': ['middleware::Test', 'controller::Home.get_home'], + '/statics/**': { handler: 'daton::static', arg: 'assets' }, }, } as RouterDefinition diff --git a/app/units.ts b/app/units.ts index d751bf2..a21750d 100644 --- a/app/units.ts +++ b/app/units.ts @@ -10,6 +10,7 @@ import { RoutingUnit, ServicesUnit, ViewEngineUnit, + DatonMiddlewareUnit, } from './bundle/daton_units.ts' export default [ @@ -19,6 +20,7 @@ export default [ ModelsUnit, ViewEngineUnit, HttpKernelUnit, + DatonMiddlewareUnit, MiddlewareUnit, ControllerUnit, RoutesUnit, diff --git a/lib/src/external/db.ts b/lib/src/external/db.ts index f3b7f82..93a37a4 100644 --- a/lib/src/external/db.ts +++ b/lib/src/external/db.ts @@ -1,4 +1,4 @@ -// export * from 'https://deno.land/x/postgres@v0.4.3/mod.ts' +// export * from 'https://deno.land/x/postgres@v0.4.4/mod.ts' // FIXME: waiting on https://github.com/deno-postgres/deno-postgres/pull/166 export * from 'https://raw.githubusercontent.com/glmdev/deno-postgres/master/mod.ts' diff --git a/lib/src/external/std.ts b/lib/src/external/std.ts index 51f2b72..3102465 100644 --- a/lib/src/external/std.ts +++ b/lib/src/external/std.ts @@ -3,4 +3,5 @@ export { config as dotenv } from 'https://deno.land/x/dotenv/mod.ts' export * as path from 'https://deno.land/std@0.67.0/path/mod.ts' export * as fs from 'https://deno.land/std@0.67.0/fs/mod.ts' export { generate as uuid } from 'https://deno.land/std@0.67.0/uuid/v4.ts' +export * as file_server from 'https://deno.land/std@0.67.0/http/file_server.ts' // export { moment } from 'https://deno.land/x/moment/moment.ts' diff --git a/lib/src/http/Response.ts b/lib/src/http/Response.ts index 7772ddc..63bd55f 100644 --- a/lib/src/http/Response.ts +++ b/lib/src/http/Response.ts @@ -1,6 +1,7 @@ import {HTTPResponse} from './type/HTTPResponse.ts' import {HTTPRequest} from './type/HTTPRequest.ts' import {ServerRequest} from '../external/http.ts' +import {file_server} from '../external/std.ts' import {CookieJar} from './CookieJar.ts' /** diff --git a/lib/src/http/kernel/module/ApplyRouteHandlers.ts b/lib/src/http/kernel/module/ApplyRouteHandlers.ts index 3ff79c6..cd24637 100644 --- a/lib/src/http/kernel/module/ApplyRouteHandlers.ts +++ b/lib/src/http/kernel/module/ApplyRouteHandlers.ts @@ -49,10 +49,10 @@ export default class ApplyRouteHandlers extends Module { for ( const handler of request.route.handlers ) { try { let result - if ( isRouteHandlerClass(handler) ) { - result = await handler.handleRequest(current_request) + if ( isRouteHandlerClass(handler.handler) ) { + result = await handler.handler.handleRequest(current_request, handler.arg) } else { - result = await handler(current_request) + result = await handler.handler(current_request, handler.arg) } if ( result instanceof Request ) { diff --git a/lib/src/http/response/FileResponseFactory.ts b/lib/src/http/response/FileResponseFactory.ts new file mode 100644 index 0000000..92b5f70 --- /dev/null +++ b/lib/src/http/response/FileResponseFactory.ts @@ -0,0 +1,45 @@ +import ResponseFactory from './ResponseFactory.ts' +import {Request} from '../Request.ts' +import {file_server} from '../../external/std.ts' +import {HTTPStatus} from '../../const/http.ts' +import {Logging} from '../../service/logging/Logging.ts' +import {Injectable} from '../../../../di/src/decorator/Injection.ts' + +/** + * Response factory to send the contents of a file given its path. + * @extends ResponseFactory + */ +@Injectable() +export default class FileResponseFactory extends ResponseFactory { + constructor( + protected readonly logger: Logging, + + /** + * The path to the file to be sent. + * @type string + */ + public readonly path: string, + ) { super() } + + /** + * Write this response factory to the given request's response. + * @param {Request} request + * @return Request + */ + public async write(request: Request): Promise { + request = await super.write(request) + + const content = await file_server.serveFile(request.to_native, this.path) + const length = content.headers && content.headers.get('content-length') + + if ( content.headers && content.body && length ) { + request.response.body = content.body + request.response.headers.set('Content-Length', length) + } else { + this.logger.debug(`Tried to serve file that does not exist: ${this.path}`) + request.response.status = HTTPStatus.NOT_FOUND + } + + return request + } +} diff --git a/lib/src/http/response/helpers.ts b/lib/src/http/response/helpers.ts index 19fab60..0ab548d 100644 --- a/lib/src/http/response/helpers.ts +++ b/lib/src/http/response/helpers.ts @@ -9,6 +9,7 @@ import {HTTPStatus} from '../../const/http.ts' import HTTPErrorResponseFactory from './HTTPErrorResponseFactory.ts' import HTTPError from '../../error/HTTPError.ts' import ViewResponseFactory from './ViewResponseFactory.ts' +import FileResponseFactory from './FileResponseFactory.ts' /** * Get a new JSON response factory that writes the given object as JSON. @@ -76,3 +77,12 @@ export function http(status: HTTPStatus, message?: string): HTTPErrorResponseFac export function view(view: string, data?: any): ViewResponseFactory { return make(ViewResponseFactory, view, data) } + +/** + * Get a new file response factory for the given file path. + * @param {string} path + * @return FileResponseFactory + */ +export function file(path: string): FileResponseFactory { + return make(FileResponseFactory, path) +} diff --git a/lib/src/http/system_middleware/StaticServer.ts b/lib/src/http/system_middleware/StaticServer.ts new file mode 100644 index 0000000..3b9ac17 --- /dev/null +++ b/lib/src/http/system_middleware/StaticServer.ts @@ -0,0 +1,75 @@ +import Middleware from '../Middleware.ts' +import {Request} from '../Request.ts' +import {Injectable} from '../../../../di/src/decorator/Injection.ts' +import {file, http} from '../response/helpers.ts' +import {HTTPStatus} from '../../const/http.ts' +import {Logging} from '../../service/logging/Logging.ts' + +/** + * Daton-provided middleware that serves files from a static directory. + * @extends Middleware + */ +@Injectable() +export default class StaticServer extends Middleware { + + constructor( + protected readonly logger: Logging, + ) { super() } + + /** + * Handle an incoming request. Get the path to the file from the route params, and + * resolve it using the asset_dir argument. If the file exists, serve it. + * @param {Request} request + * @param {string} asset_dir + */ + public async handleRequest(request: Request, asset_dir?: string) { + if ( !asset_dir ) { + throw new Error(`This static server is mis-configured. You must provide, as an argument in the route definition, the relative path to the base directory of the static files to be served.`) + } + + const params = request.route.params + const rel_file = params.$1 + + if ( !rel_file || !rel_file.trim() || rel_file.trim().startsWith('/') || rel_file.trim().startsWith('.') ) { + this.logger.info(`Blocked attempt to access invalid static file path: "${rel_file}"`) + return http(HTTPStatus.NOT_FOUND) + } + + const abs_file = this.resolve(asset_dir, rel_file) + if ( !(await this.fileExists(abs_file)) ) { + this.logger.debug(`File does not exist: ${abs_file}`) + return http(HTTPStatus.NOT_FOUND) + } + + return file(abs_file) + } + + /** + * Given the asset base dir and a relative path, resolve the fully-qualified + * path to the file. + * @param {string} asset_dir + * @param {string} rel_file + * @return string + */ + protected resolve(asset_dir: string, rel_file: string): string { + return this.app.path(asset_dir, rel_file) + } + + /** + * Resolves true if the given path exists, and is a file. + * @param {string} path + * @return Promise + */ + protected async fileExists(path: string): Promise { + try { + const stat = await Deno.lstat(path) + return stat && stat.isFile + } catch (e) { + if ( e && e instanceof Deno.errors.NotFound ) { + return false + } else { + throw e + } + } + } +} diff --git a/lib/src/http/type/RouterDefinition.ts b/lib/src/http/type/RouterDefinition.ts index 3d53963..d93b13c 100644 --- a/lib/src/http/type/RouterDefinition.ts +++ b/lib/src/http/type/RouterDefinition.ts @@ -1,4 +1,5 @@ import {logger} from '../../service/logging/global.ts' +import {RouteHandlerDefinition} from '../../unit/Routing.ts' /** * Type representing valid HTTP verbs. @@ -8,7 +9,7 @@ export type RouteVerb = 'get' | 'post' | 'patch' | 'delete' | 'head' | 'put' | ' /** * Type representing a route verb group from a router definition. */ -export type RouteVerbGroup = { [key: string]: string | string[] } +export type RouteVerbGroup = { [key: string]: RouteHandlerDefinition | RouteHandlerDefinition[] } /** * Type representing a router definition. @@ -51,10 +52,24 @@ export function isRouteVerbGroup(something: any): something is RouteVerbGroup { return false } if ( - !(typeof something[key] === 'string') - && !(Array.isArray(something[key]) && something[key].every((x: any) => typeof x === 'string')) + !(Array.isArray(something[key]) && something[key].every((x: any) => { + return ( + typeof x === 'string' + || ( + typeof x === 'object' + && typeof x.handler === 'string' + ) + ) + })) + && !( + typeof something[key] === 'string' + || ( + typeof something[key] === 'object' + && typeof something[key].handler === 'string' + ) + ) ) { - logger.info(`Route verb group for key ${key} is not a string or array of strings.`) + logger.info(`Route verb group for key ${key} is not valid. Must be valid RouteHandlerDefinition, or array of them.`) return false } } diff --git a/lib/src/unit/DatonMiddleware.ts b/lib/src/unit/DatonMiddleware.ts new file mode 100644 index 0000000..7690c70 --- /dev/null +++ b/lib/src/unit/DatonMiddleware.ts @@ -0,0 +1,14 @@ +import {FakeCanonical} from './FakeCanonical.ts' +import Middleware from '../http/Middleware.ts' +import {Unit} from '../lifecycle/decorators.ts' +import StaticServer from '../http/system_middleware/StaticServer.ts' + +@Unit() +export default class DatonMiddleware extends FakeCanonical { + protected canonical_item = 'daton' + + public async up() { + await super.up() + this._items['static'] = this.make(StaticServer) + } +} diff --git a/lib/src/unit/FakeCanonical.ts b/lib/src/unit/FakeCanonical.ts new file mode 100644 index 0000000..0fa7ec1 --- /dev/null +++ b/lib/src/unit/FakeCanonical.ts @@ -0,0 +1,10 @@ +import {Canonical} from './Canonical.ts' +import {Canon} from './Canon.ts' + +export class FakeCanonical extends Canonical { + + public async up() { + this.make(Canon).register_canonical(this) + } + +} diff --git a/lib/src/unit/Routing.ts b/lib/src/unit/Routing.ts index ae16ade..29e7a1b 100644 --- a/lib/src/unit/Routing.ts +++ b/lib/src/unit/Routing.ts @@ -19,19 +19,28 @@ import {RegExRoute} from "../http/routing/RegExRoute.ts"; export type RouteHandlerReturnValue = SyncRouteHandlerReturnValue | AsyncRouteHandlerReturnValue export type SyncRouteHandlerReturnValue = Request | ResponseFactory | void | undefined export type AsyncRouteHandlerReturnValue = Promise -export type RouteHandlerFunction = (request: Request) => RouteHandlerReturnValue +export type RouteHandlerFunction = ((request: Request, arg?: any) => RouteHandlerReturnValue) | ((request: Request) => RouteHandlerReturnValue) +export interface RouteHandlerWithArgument { + handler: string, + arg: any, +} +export type RouteHandlerDefinition = string | RouteHandlerWithArgument /** * A class that can handle requests. */ export interface RouteHandlerClass { handleRequest(request: Request): RouteHandlerReturnValue + handleRequest(request: Request, arg?: any): RouteHandlerReturnValue } /** * Base type defining a single route handler. */ -export type RouteHandler = RouteHandlerFunction | RouteHandlerClass +export interface RouteHandler { + handler: RouteHandlerFunction | RouteHandlerClass, + arg: any, +} /** * Base type for a collection of route handlers. @@ -60,8 +69,12 @@ export interface RouteDefinition { */ export function isRouteHandler(what: any): what is RouteHandler { return ( - typeof what === 'function' - || isRouteHandlerClass(what) + typeof what === 'object' + && what.handler + && ( + typeof what.handler === 'function' + || isRouteHandlerClass(what.handler) + ) ) } @@ -72,7 +85,7 @@ export function isRouteHandler(what: any): what is RouteHandler { */ export function isRouteHandlerClass(what: any): what is RouteHandlerClass { return ( - what && typeof what.handleRequest === 'function' && what.handleRequest.length === 1 + what && typeof what.handleRequest === 'function' && what.handleRequest.length >= 1 ) } @@ -139,13 +152,15 @@ export default class Routing extends LifecycleUnit { * @param {Array} group * @return RouteHandlers */ - public build_handler(group: string[]): RouteHandlers { + public build_handler(group: RouteHandlerDefinition[]): RouteHandlers { const handlers: RouteHandlers = [] for ( const item of group ) { - const ref = Canonical.resolve(item) + const handler_ref = typeof item === 'object' ? item.handler : item + const arg: any = typeof item === 'object' ? item.arg : undefined + const ref = Canonical.resolve(handler_ref) if ( !ref.resource ) { - this.logger.error(`Invalid canonical reference for route: ${item}. Reference must include resource (e.g. controller::)!`) + this.logger.error(`Invalid canonical reference for route: ${handler_ref}. Reference must include resource (e.g. controller::)!`) continue } @@ -153,10 +168,15 @@ export default class Routing extends LifecycleUnit { const resolved = resource.get(ref.item) if ( !ref.particular ) { - if ( isRouteHandler(resolved) ) { - handlers.push(resolved) + const handler = { + handler: resolved, + arg, + } + + if ( isRouteHandler(handler) ) { + handlers.push(handler) } else { - throw new TypeError(`Invalid canonical reference for route: ${item}. Reference is not a valid route handler.`) + throw new TypeError(`Invalid canonical reference for route: ${handler_ref}. Reference is not a valid route handler.`) } } else { if ( isBindable(resolved) ) { @@ -165,16 +185,21 @@ export default class Routing extends LifecycleUnit { handler = resolved.get_bound_method(ref.particular) } catch (e) { this.logger.error(e) - throw new Error(`Invalid canonical reference for route: ${item}. Reference particular could not be bound.`) + throw new Error(`Invalid canonical reference for route: ${handler_ref}. Reference particular could not be bound.`) + } + + const handler_obj = { + handler, + arg } - if ( isRouteHandler(handler) ) { - handlers.push(handler) + if ( isRouteHandler(handler_obj) ) { + handlers.push(handler_obj) } else { - throw new TypeError(`Invalid canonical reference for route: ${item}. Reference is not a valid route handler.`) + throw new TypeError(`Invalid canonical reference for route: ${handler_ref}. Reference is not a valid route handler.`) } } else { - throw new TypeError(`Invalid canonical reference for route: ${item}. Reference specifies particular, but resolved resource is not bindable.`) + throw new TypeError(`Invalid canonical reference for route: ${handler_ref}. Reference specifies particular, but resolved resource is not bindable.`) } } } diff --git a/orm/src/model/relation/decorators.ts b/orm/src/model/relation/decorators.ts index 6a82d02..13dcf68 100644 --- a/orm/src/model/relation/decorators.ts +++ b/orm/src/model/relation/decorators.ts @@ -6,7 +6,6 @@ import {Model} from '../Model.ts' */ export function Relation(): MethodDecorator { return (target: any, propertyKey, descriptor) => { - console.log('relation decorator', target, propertyKey, descriptor) // @ts-ignore const original = descriptor.value