diff --git a/src/auth/Authentication.ts b/src/auth/Authentication.ts index 825ac04..a22a176 100644 --- a/src/auth/Authentication.ts +++ b/src/auth/Authentication.ts @@ -7,6 +7,8 @@ import {Middleware} from '../http/routing/Middleware' import {AuthRequiredMiddleware} from './middleware/AuthRequiredMiddleware' import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware' import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware' +import {ViewEngine} from '../views/ViewEngine' +import {SecurityContext} from './context/SecurityContext' @Injectable() export class Authentication extends Unit { @@ -18,6 +20,13 @@ export class Authentication extends Unit { async up(): Promise { this.middleware.registerNamespace('@auth', this.getMiddlewareResolver()) + + this.container().onResolve(ViewEngine) + .then((engine: ViewEngine) => { + engine.registerGlobalFactory('user', req => { + return () => req?.make(SecurityContext)?.getUser() + }) + }) } protected getMiddlewareResolver(): CanonicalResolver> { diff --git a/src/http/RequestLocalStorage.ts b/src/http/RequestLocalStorage.ts new file mode 100644 index 0000000..bef62b0 --- /dev/null +++ b/src/http/RequestLocalStorage.ts @@ -0,0 +1,32 @@ +import { AsyncLocalStorage } from 'async_hooks' +import {Request} from './lifecycle/Request' +import {Singleton} from '../di' +import {ErrorWithContext} from '../util' + +export class InvalidOutOfRequestAccessError extends ErrorWithContext { + constructor() { + super(`Attempted to access request via local storage outside of async lifecycle!`) + } +} + +@Singleton() +export class RequestLocalStorage { + protected readonly store: AsyncLocalStorage = new AsyncLocalStorage() + + get(): Request { + const req = this.store.getStore() + if ( !req ) { + throw new InvalidOutOfRequestAccessError() + } + + return req + } + + has(): boolean { + return Boolean(this.store.getStore()) + } + + run(req: Request, closure: () => T): T { + return this.store.run(req, closure) + } +} diff --git a/src/index.ts b/src/index.ts index c3205c2..6dc3d69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ export * from './http/kernel/HTTPCookieJar' export * from './http/lifecycle/Request' export * from './http/lifecycle/Response' +export * from './http/RequestLocalStorage' export * as api from './http/response/api' export * from './http/response/DehydratedStateResponseFactory' diff --git a/src/resources/assets/extollo.svg b/src/resources/assets/extollo.svg new file mode 100644 index 0000000..9fcff29 --- /dev/null +++ b/src/resources/assets/extollo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/resources/views/base.pug b/src/resources/views/base.pug new file mode 100644 index 0000000..791b23b --- /dev/null +++ b/src/resources/views/base.pug @@ -0,0 +1,17 @@ +doctype html +html + head + meta(charset='utf-8') + meta(name='viewport' content='width=device-width, initial-scale=1') + block head + block styles + block prescripts + script. + if (typeof $ !== 'function') { + window.$ = (...args) => document.querySelector(...args) + } + script(src=vendor('@extollo/ui', 'extollo-ui.dist.js') type='module') + body + ex-page + block content + block scripts diff --git a/src/resources/views/welcome.pug b/src/resources/views/welcome.pug new file mode 100644 index 0000000..b148827 --- /dev/null +++ b/src/resources/views/welcome.pug @@ -0,0 +1,18 @@ +extends base + +block content + ex-nav + img(src=vendor('@extollo/lib', 'extollo.svg') slot='branding' width=115 style="margin-right: 30px" alt=config('app.name')) + ex-nav-item(title='Home' name='home' href=route('/')) + ex-nav-item(title='Documentation' name='documentation' href='https://extollo.garrettmills.dev') + + if hasRoute('@auth.login') + if user() + ex-nav-item(title='Welcome, ' + user().getDisplay() name='user' href=named('@auth.logout') right) + else + ex-nav-item(title='Login' name='login' href=named('@auth.login') right) + + h1 Welcome to #{config('app.name')} + p You have successfully created your new application, #{config('app.name')}, based on the Extollo framework. + p For more information on Extollo, visit the framework documentation using the link above. + blockquote You can customize this view by modifying src/app/resources/views/welcome.pug. diff --git a/src/service/HTTPServer.ts b/src/service/HTTPServer.ts index 8c22d6a..ea237d5 100644 --- a/src/service/HTTPServer.ts +++ b/src/service/HTTPServer.ts @@ -19,6 +19,7 @@ import {Config} from './Config' import {InjectRequestEventBusHTTPModule} from '../http/kernel/module/InjectRequestEventBusHTTPModule' import {Routing} from './Routing' import {EventBus} from '../event/EventBus' +import {RequestLocalStorage} from '../http/RequestLocalStorage' /** * Application unit that starts the HTTP/S server, creates Request and Response objects @@ -41,6 +42,9 @@ export class HTTPServer extends Unit { @Inject() protected readonly bus!: EventBus + @Inject() + protected readonly requestLocalStorage!: RequestLocalStorage + /** The underlying native Node.js server. */ protected server?: Server @@ -88,40 +92,42 @@ export class HTTPServer extends Unit { return async (request: IncomingMessage, response: ServerResponse) => { const extolloReq = new Request(request, response) - withTimeout(timeout, extolloReq.response.sent$.toPromise()) - .onTime(() => { - this.logging.verbose(`Request lifecycle finished on time. (Path: ${extolloReq.path})`) - }) - .late(() => { - if ( !extolloReq.bypassTimeout ) { - this.logging.warn(`Request lifecycle finished late, so an error response was returned! (Path: ${extolloReq.path})`) - } - }) - .timeout(() => { - if ( extolloReq.bypassTimeout ) { - this.logging.info(`Request lifecycle has timed out, but bypassRequest was set. (Path: ${extolloReq.path})`) - return + await this.requestLocalStorage.run(extolloReq, async () => { + withTimeout(timeout, extolloReq.response.sent$.toPromise()) + .onTime(() => { + this.logging.verbose(`Request lifecycle finished on time. (Path: ${extolloReq.path})`) + }) + .late(() => { + if ( !extolloReq.bypassTimeout ) { + this.logging.warn(`Request lifecycle finished late, so an error response was returned! (Path: ${extolloReq.path})`) + } + }) + .timeout(() => { + if ( extolloReq.bypassTimeout ) { + this.logging.info(`Request lifecycle has timed out, but bypassRequest was set. (Path: ${extolloReq.path})`) + return + } + + this.logging.error(`Request lifecycle has timed out. Will send error response instead. (Path: ${extolloReq.path})`) + extolloReq.response.setStatus(HTTPStatus.REQUEST_TIMEOUT) + extolloReq.response.body = 'Sorry, your request timed out.' + extolloReq.response.send() + }) + .run() + .catch(e => this.logging.error(e)) + + try { + await this.kernel.handle(extolloReq) + } catch (e) { + if ( e instanceof Error ) { + await error(e).write(extolloReq) } - this.logging.error(`Request lifecycle has timed out. Will send error response instead. (Path: ${extolloReq.path})`) - extolloReq.response.setStatus(HTTPStatus.REQUEST_TIMEOUT) - extolloReq.response.body = 'Sorry, your request timed out.' - extolloReq.response.send() - }) - .run() - .catch(e => this.logging.error(e)) - - try { - await this.kernel.handle(extolloReq) - } catch (e) { - if ( e instanceof Error ) { - await error(e).write(extolloReq) + await error(new ErrorWithContext('Unknown error occurred.', { e })) } - await error(new ErrorWithContext('Unknown error occurred.', { e })) - } - - await extolloReq.response.send() + await extolloReq.response.send() + }) } } } diff --git a/src/views/ViewEngine.ts b/src/views/ViewEngine.ts index bd1c450..968c64a 100644 --- a/src/views/ViewEngine.ts +++ b/src/views/ViewEngine.ts @@ -1,8 +1,10 @@ import {AppClass} from '../lifecycle/AppClass' import {Config} from '../service/Config' import {Container} from '../di' -import {ErrorWithContext, UniversalPath} from '../util' +import {ErrorWithContext, hasOwnProperty, Maybe, UniversalPath} from '../util' import {Routing} from '../service/Routing' +import {RequestLocalStorage} from '../http/RequestLocalStorage' +import {Request} from '../http/lifecycle/Request' /** * Abstract base class for rendering views via different view engines. @@ -12,14 +14,19 @@ export abstract class ViewEngine extends AppClass { protected readonly routing: Routing + protected readonly request: RequestLocalStorage + protected readonly debug: boolean protected readonly namespaces: {[key: string]: UniversalPath} = {} + protected readonly globals: {[key: string]: (req: Maybe) => any} = {} + constructor() { super() this.config = Container.getContainer().make(Config) this.routing = Container.getContainer().make(Routing) + this.request = Container.getContainer().make(RequestLocalStorage) this.debug = (this.config.get('server.mode', 'production') === 'development' || this.config.get('server.debug', false)) } @@ -56,7 +63,7 @@ export abstract class ViewEngine extends AppClass { * @protected */ protected getGlobals(): {[key: string]: any} { - return { + const globals: {[key: string]: any} = { app: this.app(), config: (key: string, fallback?: any) => this.config.get(key, fallback), asset: (...parts: string[]) => this.routing.getAssetPath(...parts).toRemote, @@ -65,6 +72,31 @@ export abstract class ViewEngine extends AppClass { route: (...parts: string[]) => this.routing.getAppUrl().concat(...parts).toRemote, hasRoute: (name: string) => this.routing.hasNamedRoute(name), } + + const req = this.request.get() + if ( req ) { + globals.request = () => req + } + + for ( const key in this.globals ) { + if ( !hasOwnProperty(this.globals, key) ) { + continue + } + + globals[key] = this.globals[key](req) + } + + return globals + } + + /** + * Register a new factory that produces a global available in the templates by default. + * @param name + * @param factory + */ + public registerGlobalFactory(name: string, factory: (req: Maybe) => any): this { + this.globals[name] = factory + return this } /**