Named routes & basic login framework

orm-types
Garrett Mills 3 years ago
parent e33d8dee8f
commit e86cf420df
Signed by: garrettmills
GPG Key ID: D2BF5FBA8298F246

@ -0,0 +1,24 @@
import {FormRequest, ValidationRules} from '../../forms'
import {Is, Str} from '../../forms/rules/rules'
import {Singleton} from '../../di'
export interface BasicLoginCredentials {
username: string,
password: string,
}
@Singleton()
export class BasicLoginFormRequest extends FormRequest<BasicLoginCredentials> {
protected getRules(): ValidationRules {
return {
username: [
Is.required,
Str.lengthMin(1),
],
password: [
Is.required,
Str.lengthMin(1),
],
}
}
}

@ -19,3 +19,5 @@ export * from './middleware/SessionAuthMiddleware'
export * from './Authentication'
export * from './config'
export * from './basic-ui/BasicLoginFormRequest'

@ -5,15 +5,30 @@ import {ResponseObject} from '../../http/routing/Route'
import {error} from '../../http/response/ErrorResponseFactory'
import {NotAuthorizedError} from '../NotAuthorizedError'
import {HTTPStatus} from '../../util'
import {redirect} from '../../http/response/RedirectResponseFactory'
import {Routing} from '../../service/Routing'
import {Session} from '../../http/session/Session'
@Injectable()
export class AuthRequiredMiddleware extends Middleware {
@Inject()
protected readonly security!: SecurityContext
@Inject()
protected readonly routing!: Routing
@Inject()
protected readonly session!: Session
async apply(): Promise<ResponseObject> {
if ( !this.security.hasUser() ) {
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
this.session.set('auth.intention', this.request.url)
if ( this.routing.hasNamedRoute('@auth.login') ) {
return redirect(this.routing.getNamedPath('@auth.login').toRemote)
} else {
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
}
}
}
}

@ -5,15 +5,24 @@ import {ResponseObject} from '../../http/routing/Route'
import {error} from '../../http/response/ErrorResponseFactory'
import {NotAuthorizedError} from '../NotAuthorizedError'
import {HTTPStatus} from '../../util'
import {Routing} from '../../service/Routing'
import {redirect} from '../../http/response/RedirectResponseFactory'
@Injectable()
export class GuestRequiredMiddleware extends Middleware {
@Inject()
protected readonly security!: SecurityContext
@Inject()
protected readonly routing!: Routing
async apply(): Promise<ResponseObject> {
if ( this.security.hasUser() ) {
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
if ( this.routing.hasNamedRoute('@auth.redirectFromGuest') ) {
return redirect(this.routing.getNamedPath('@auth.redirectFromGuest').toRemote)
} else {
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
}
}
}
}

@ -5,6 +5,7 @@ import {Logging} from '../../service/Logging'
import {AppClass} from '../../lifecycle/AppClass'
import {Request} from '../lifecycle/Request'
import {error} from '../response/ErrorResponseFactory'
import {HTTPError} from '../HTTPError'
/**
* Interface for fluently registering kernel modules into the kernel.
@ -105,7 +106,8 @@ export class HTTPKernel extends AppClass {
}
} catch (e: any) {
this.logging.error(e)
await error(e).status(HTTPStatus.INTERNAL_SERVER_ERROR)
const status = (e instanceof HTTPError && e.status) ? e.status : HTTPStatus.INTERNAL_SERVER_ERROR
await error(e).status(status)
.write(request)
}

@ -138,7 +138,6 @@ export class ParseIncomingBodyHTTPModule extends HTTPKernelModule {
})
busboy.on('finish', () => {
this.logging.debug(`Parsed body input: ${JSON.stringify(request.parsedInput)}`)
res()
})

@ -21,6 +21,7 @@ export class PoweredByHeaderInjectionHTTPModule extends HTTPKernelModule {
public async apply(request: Request): Promise<Request> {
if ( !this.config.get('server.poweredBy.hide', false) ) {
request.response.setHeader('X-Powered-By', this.config.get('server.poweredBy.header', 'Extollo'))
request.response.setHeader('Server', this.config.get('server.poweredBy.header', 'Extollo'))
}
return request

@ -229,6 +229,9 @@ export class Response {
this.setHeader('Content-Length', String(this.body?.length ?? 0))
}
this.setHeader('Date', (new Date()).toUTCString())
this.setHeader('Permissions-Policy', 'interest-cohort=()')
await this.write(this.body ?? '')
this.end()

@ -0,0 +1,31 @@
import {ResponseFactory} from './ResponseFactory'
import {HTTPStatus} from '../../util'
import {Request} from '../lifecycle/Request'
/**
* Helper function to create a new RedirectResponseFactory to the given destination.
* @param destination
*/
export function redirect(destination: string): RedirectResponseFactory {
return new RedirectResponseFactory(destination)
}
/**
* Response factory that sends an HTTP redirect to the given destination.
*/
export class RedirectResponseFactory extends ResponseFactory {
protected targetStatus: HTTPStatus = HTTPStatus.MOVED_TEMPORARILY
constructor(
/** THe URL where the client should redirect to. */
public readonly destination: string,
) {
super()
}
public async write(request: Request): Promise<Request> {
request = await super.write(request)
request.response.setHeader('Location', this.destination)
return request
}
}

@ -0,0 +1,40 @@
import {ResponseFactory} from './ResponseFactory'
import {HTTPStatus} from '../../util'
import {Request} from '../lifecycle/Request'
import {Routing} from '../../service/Routing'
/**
* Helper function to create a new RouteResponseFactory to the given destination.
* @param nameOrPath
*/
export function route(nameOrPath: string): RouteResponseFactory {
return new RouteResponseFactory(nameOrPath)
}
/**
* Response factory that sends an HTTP redirect to the given destination.
*/
export class RouteResponseFactory extends ResponseFactory {
protected targetStatus: HTTPStatus = HTTPStatus.MOVED_TEMPORARILY
constructor(
/** The alias or path of the route to redirect to. */
public readonly nameOrPath: string,
) {
super()
}
public async write(request: Request): Promise<Request> {
const routing = <Routing> request.make(Routing)
request = await super.write(request)
try {
const routePath = routing.getNamedPath(this.nameOrPath)
request.response.setHeader('Location', routePath.toRemote)
} catch (e: unknown) {
request.response.setHeader('Location', routing.getAppUrl().concat(this.nameOrPath).toRemote)
}
return request
}
}

@ -6,7 +6,7 @@ import {Request} from '../lifecycle/Request'
* Helper function to create a new TemporaryRedirectResponseFactory to the given destination.
* @param destination
*/
export function redirect(destination: string): TemporaryRedirectResponseFactory {
export function temporary(destination: string): TemporaryRedirectResponseFactory {
return new TemporaryRedirectResponseFactory(destination)
}

@ -215,6 +215,9 @@ export class Route extends AppClass {
/** Pre-compiled route handler for the main route handler for this route. */
protected compiledPostflight?: ResolvedRouteHandler[]
/** Programmatic aliases of this route. */
public aliases: string[] = []
constructor(
/** The HTTP method(s) that this route listens on. */
protected method: HTTPMethod | HTTPMethod[],
@ -228,6 +231,15 @@ export class Route extends AppClass {
super()
}
/**
* Set a programmatic name for this route.
* @param name
*/
public alias(name: string): this {
this.aliases.push(name)
return this
}
/**
* Get the string-form of the route.
*/

@ -5,7 +5,7 @@ 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 {redirect} from '../response/RedirectResponseFactory'
import {file} from '../response/FileResponseFactory'
import {RouteHandler} from '../routing/Route'
@ -24,6 +24,9 @@ export interface StaticServerOptions {
/** 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
}
/**
@ -156,6 +159,13 @@ export function staticServer(options: StaticServerOptions = {}): RouteHandler {
// 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(),

@ -93,4 +93,12 @@ export class MemorySession extends Session {
this.data[key] = value
}
public forget(key: string): void {
if ( !this.data ) {
throw new SessionNotLoadedError()
}
delete this.data[key]
}
}

@ -57,4 +57,7 @@ export abstract class Session {
/** Set a value in the session by key. */
public abstract set(key: string, value: unknown): void
/** Remove a key from the session data. */
public abstract forget(key: string): void
}

@ -42,8 +42,10 @@ export * from './http/response/JSONResponseFactory'
export * from './http/response/ResponseFactory'
export * from './http/response/StringResponseFactory'
export * from './http/response/TemporaryRedirectResponseFactory'
export * from './http/response/RedirectResponseFactory'
export * from './http/response/ViewResponseFactory'
export * from './http/response/FileResponseFactory'
export * from './http/response/RouteResponseFactory'
export * from './http/routing/ActivatedRoute'
export * from './http/routing/Route'

@ -68,6 +68,7 @@ export class ORMSession extends Session {
if ( !this.data ) {
throw new SessionNotLoadedError()
}
return this.data[key] ?? fallback
}
@ -75,6 +76,15 @@ export class ORMSession extends Session {
if ( !this.data ) {
throw new SessionNotLoadedError()
}
this.data[key] = value
}
public forget(key: string): void {
if ( !this.data ) {
throw new SessionNotLoadedError()
}
delete this.data[key]
}
}

@ -0,0 +1,116 @@
:root {
--input-padding-x: 1.5rem;
--input-padding-y: 0.75rem;
}
.login,
.image {
min-height: 100vh;
}
.login-heading {
font-weight: 300;
}
.btn-login {
font-size: 0.9rem;
letter-spacing: 0.05rem;
padding: 0.75rem 1rem;
border-radius: 2rem;
}
.form-label-group {
position: relative;
margin-bottom: 1rem;
}
.form-label-group>input,
.form-label-group>label {
padding: var(--input-padding-y) var(--input-padding-x);
height: auto;
border-radius: 2rem;
}
.form-label-group>label {
position: absolute;
top: 0;
left: 0;
display: block;
width: 100%;
margin-bottom: 0;
/* Override default `<label>` margin */
line-height: 1.5;
color: #495057;
cursor: text;
/* Match the input under the label */
border: 1px solid transparent;
border-radius: .25rem;
transition: all .1s ease-in-out;
}
.form-label-group input::-webkit-input-placeholder {
color: transparent;
}
.form-label-group input:-ms-input-placeholder {
color: transparent;
}
.form-label-group input::-ms-input-placeholder {
color: transparent;
}
.form-label-group input::-moz-placeholder {
color: transparent;
}
.form-label-group input::placeholder {
color: transparent;
}
.form-label-group input:not(:placeholder-shown) {
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
padding-bottom: calc(var(--input-padding-y) / 3);
}
.form-label-group input:not(:placeholder-shown)~label {
padding-top: calc(var(--input-padding-y) / 3);
padding-bottom: calc(var(--input-padding-y) / 3);
font-size: 12px;
color: #777;
}
.form-error-message {
color: darkred;
font-weight: bold;
}
.form-submit-button {
margin-top: 50px;
margin-bottom: 50px;
}
/* Fallback for Edge
-------------------------------------------------- */
@supports (-ms-ime-align: auto) {
.form-label-group>label {
display: none;
}
.form-label-group input::-ms-input-placeholder {
color: #777;
}
}
/* Fallback for IE
-------------------------------------------------- */
@media all and (-ms-high-contrast: none),
(-ms-high-contrast: active) {
.form-label-group>label {
display: none;
}
.form-label-group input:-ms-input-placeholder {
color: #777;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -4,19 +4,21 @@ block head
title Login | #{config('app.name', 'Extollo')}
block heading
| Login to Continue
| Login to continue
block form
.form-label-group
input#inputUsername.form-control(type='text' name='username' value=(form_data ? form_data.username : '') required placeholder='Username' autofocus)
input#inputUsername.form-control(type='text' name='username' value=(formData ? formData.username : '') required placeholder='Username' autofocus)
label(for='inputUsername') Username
.form-label-group
input#inputPassword.form-control(type='password' name='password' required placeholder='Password')
label(for='inputPassword') Password
button.btn.btn-lg.btn-primary.btn-block.btn-login.text-uppercase.font-weight-bold.mb-2.form-submit-button(type='submit') Login
.text-center
span.small Need an account?&nbsp;
a(href='./register') Register here.
// .text-center
span.small(style="color: #999999;") Provider: #{provider_name}

@ -0,0 +1,12 @@
extends ./theme
block content
if heading
h3.login-heading.mb-4 #{heading}
if errors
each error in errors
p.form-error-message #{error}
if message
p #{message}

@ -5,18 +5,17 @@ html
block head
block styles
link(rel='stylesheet' href='https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css')
link(rel='stylesheet' href=vendor('@extollo', 'auth/theme.css'))
link(rel='stylesheet' href=vendor('@extollo/lib', 'lib/bootstrap.min.css'))
link(rel='stylesheet' href=vendor('@extollo/lib', 'auth/theme.css'))
body
.container-fluid
.row.no-gutter
.d-none.d-md-flex.col-md-6.col-lg-8.bg-image
.col-md-6.col-lg-4
.col-md-12.col-lg-12
.login.d-flex.align-items-center.py-5
.container
.row
.col-md-9.col-lg-8.mx-auto
.col-md-9.col-lg-6.mx-auto
block content
block scripts
script(src='https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css')
script(src=vendor('@extollo/lib', 'lib/bootstrap.min.js'))

@ -1,5 +1,5 @@
import {Inject, Singleton} from '../di'
import {HTTPStatus, universalPath, UniversalPath, withTimeout} from '../util'
import {HTTPStatus, withTimeout} from '../util'
import {Unit} from '../lifecycle/Unit'
import {createServer, IncomingMessage, RequestListener, Server, ServerResponse} from 'http'
import {Logging} from './Logging'
@ -18,10 +18,7 @@ import {ParseIncomingBodyHTTPModule} from '../http/kernel/module/ParseIncomingBo
import {Config} from './Config'
import {InjectRequestEventBusHTTPModule} from '../http/kernel/module/InjectRequestEventBusHTTPModule'
import {Routing} from './Routing'
import {Route} from '../http/routing/Route'
import {staticServer} from '../http/servers/static'
import {EventBus} from '../event/EventBus'
import {PackageDiscovered} from '../support/PackageDiscovered'
/**
* Application unit that starts the HTTP/S server, creates Request and Response objects
@ -47,30 +44,6 @@ export class HTTPServer extends Unit {
/** The underlying native Node.js server. */
protected server?: Server
/**
* Register an asset directory for the given package.
* This creates a static server route for the package with the
* configured vendor prefix.
* @param packageName
* @param basePath
*/
public async registerVendorAssets(packageName: string, basePath: UniversalPath): Promise<void> {
if ( this.config.get('server.builtIns.vendor.enabled', true) ) {
this.logging.debug(`Registering vendor assets route for package ${packageName} on ${basePath}...`)
await this.routing.registerRoutes(() => {
const prefix = this.config.get('server.builtIns.vendor.prefix', '/vendor')
Route.group(prefix, () => {
Route.group(packageName, () => {
Route.get('/**', staticServer({
basePath,
directoryListing: false,
}))
})
})
})
}
}
public async up(): Promise<void> {
const port = this.config.get('server.port', 8000)
@ -86,8 +59,6 @@ export class HTTPServer extends Unit {
ParseIncomingBodyHTTPModule.register(this.kernel)
InjectRequestEventBusHTTPModule.register(this.kernel)
await this.registerBuiltIns()
await new Promise<void>(res => {
this.server = createServer(this.handler)
@ -148,34 +119,4 @@ export class HTTPServer extends Unit {
await extolloReq.response.send()
}
}
/** Register built-in servers and routes. */
protected async registerBuiltIns(): Promise<void> {
const extolloAssets = universalPath(__dirname, '..', 'resources', 'assets')
await this.registerVendorAssets('@extollo/lib', extolloAssets)
this.bus.subscribe(PackageDiscovered, async (event: PackageDiscovered) => {
if ( event.packageConfig?.extollo?.assets?.discover && event.packageConfig.name ) {
this.logging.debug(`Registering vendor assets for discovered package: ${event.packageConfig.name}`)
const basePath = event.packageConfig?.extollo?.assets?.basePath
if ( basePath && Array.isArray(basePath) ) {
const assetPath = event.packageJson.concat('..', ...basePath)
await this.registerVendorAssets(event.packageConfig.name, assetPath)
}
}
})
if ( this.config.get('server.builtIns.assets.enabled', true) ) {
const prefix = this.config.get('server.builtIns.assets.prefix', '/assets')
this.logging.debug(`Registering built-in assets server with prefix: ${prefix}`)
await this.routing.registerRoutes(() => {
Route.group(prefix, () => {
Route.get('/**', staticServer({
directoryListing: false,
basePath: ['resources', 'assets'],
}))
})
})
}
}
}

@ -1,5 +1,5 @@
import {Inject, Singleton} from '../di'
import {Awaitable, Collection, Pipe, universalPath, UniversalPath} from '../util'
import {Awaitable, Collection, ErrorWithContext, Maybe, Pipe, universalPath, UniversalPath} from '../util'
import {Unit, UnitStatus} from '../lifecycle/Unit'
import {Logging} from './Logging'
import {Route} from '../http/routing/Route'
@ -10,6 +10,7 @@ import {lib} from '../lib'
import {Config} from './Config'
import {EventBus} from '../event/EventBus'
import {PackageDiscovered} from '../support/PackageDiscovered'
import {staticServer} from '../http/servers/static'
/**
* Application unit that loads the various route files from `app/http/routes` and pre-compiles the route handlers.
@ -43,6 +44,8 @@ export class Routing extends Unit {
await import(entry)
}
await this.registerBuiltIns()
this.logging.info('Compiling routes...')
this.compiledRoutes = new Collection<Route>(await Route.compile())
@ -140,6 +143,26 @@ export class Routing extends Unit {
return this.getAppUrl().concat(this.config.get('server.builtIns.vendor.prefix', '/vendor'))
}
public getNamedPath(name: string): UniversalPath {
const route = this.getByName(name)
if ( route ) {
return this.getAppUrl().concat(route.getRoute())
}
throw new ErrorWithContext(`Route does not exist with name: ${name}`, {
routeName: name,
})
}
public hasNamedRoute(name: string): boolean {
return Boolean(this.getByName(name))
}
public getByName(name: string): Maybe<Route> {
return this.compiledRoutes
.firstWhere(route => route.aliases.includes(name))
}
public getAppUrl(): UniversalPath {
const rawHost = String(this.config.get('server.url', 'http://localhost')).toLowerCase()
const isSSL = rawHost.startsWith('https://')
@ -166,4 +189,58 @@ export class Routing extends Unit {
.tap<UniversalPath>(host => universalPath(host))
.get()
}
/**
* Register an asset directory for the given package.
* This creates a static server route for the package with the
* configured vendor prefix.
* @param packageName
* @param basePath
*/
public async registerVendorAssets(packageName: string, basePath: UniversalPath): Promise<void> {
if ( this.config.get('server.builtIns.vendor.enabled', true) ) {
this.logging.debug(`Registering vendor assets route for package ${packageName} on ${basePath}...`)
await this.registerRoutes(() => {
const prefix = this.config.get('server.builtIns.vendor.prefix', '/vendor')
Route.group(prefix, () => {
Route.group(packageName, () => {
Route.get('/**', staticServer({
basePath,
directoryListing: false,
}))
})
})
})
}
}
/** Register built-in servers and routes. */
protected async registerBuiltIns(): Promise<void> {
const extolloAssets = universalPath(__dirname, '..', 'resources', 'assets')
await this.registerVendorAssets('@extollo/lib', extolloAssets)
this.bus.subscribe(PackageDiscovered, async (event: PackageDiscovered) => {
if ( event.packageConfig?.extollo?.assets?.discover && event.packageConfig.name ) {
this.logging.debug(`Registering vendor assets for discovered package: ${event.packageConfig.name}`)
const basePath = event.packageConfig?.extollo?.assets?.basePath
if ( basePath && Array.isArray(basePath) ) {
const assetPath = event.packageJson.concat('..', ...basePath)
await this.registerVendorAssets(event.packageConfig.name, assetPath)
}
}
})
if ( this.config.get('server.builtIns.assets.enabled', true) ) {
const prefix = this.config.get('server.builtIns.assets.prefix', '/assets')
this.logging.debug(`Registering built-in assets server with prefix: ${prefix}`)
await this.registerRoutes(() => {
Route.group(prefix, () => {
Route.get('/**', staticServer({
directoryListing: false,
basePath: ['resources', 'assets'],
}))
})
})
}
}
}

@ -61,6 +61,9 @@ export abstract class ViewEngine extends AppClass {
config: (key: string, fallback?: any) => this.config.get(key, fallback),
asset: (...parts: string[]) => this.routing.getAssetPath(...parts).toRemote,
vendor: (namespace: string, ...parts: string[]) => this.routing.getVendorPath(namespace, ...parts).toRemote,
named: (name: string) => this.routing.getNamedPath(name).toRemote,
route: (...parts: string[]) => this.routing.getAppUrl().concat(...parts).toRemote,
hasRoute: (name: string) => this.routing.hasNamedRoute(name),
}
}

Loading…
Cancel
Save