// noinspection JSUnusedGlobalSymbols import {IHttpRequest, IHttpResponse, IRoute} from './interfaces'; import {inject, injectable} from 'inversify'; import {RequestSymbol, ResponseSymbol} from './symbols'; import {BadRequestError, RequestError} from './errors'; import {StatusCode} from './status-code'; import {createHash} from 'crypto'; import {pipeline, Readable} from 'stream'; import read from '@ts-awesome/model-reader'; import { IProfilingSession, ProfilingSessionSymbol, serverTimingReporter } from "@ts-awesome/profiler"; declare type Class = new (...args: any) => any; export interface IOutputBuilder { build(models: readonly T[], ...args: unknown[]): Promise } interface ISimpleValidator { validate(value: T): true | readonly string[]; } interface IValidatorWithOptions { validate(value: T, options?: X & {restrictExtraFields?: boolean}): true | readonly string[]; } type IValidator = ISimpleValidator | IValidatorWithOptions; export interface ETaggable { readonly uid: string; readonly lastModified: Date; readonly version?: number; } function etag(uid: string, lastModified: Date, version= 0) { return JSON.stringify(new Buffer(`${uid}-${version}-${lastModified.getTime()}`).toString('base64')); } function sha256_hex(data: string | Buffer): string { return createHash('sha256').update(data).digest().toString('hex'); } function sha256_base64(data: string | Buffer): string { return createHash('sha256').update(data).digest().toString('base64'); } function etagList(list: readonly ETaggable[] | Iterable): [string, Date] { let lastModified = new Date(0); const uid: string[] = []; for(const item of list) { lastModified = lastModified > item.lastModified ? lastModified : item.lastModified; uid.push(`${etag(item.uid, item.lastModified, item.version ?? 0)}`); } return [etag(sha256_hex(uid.join(',')), lastModified, uid.length), lastModified]; } function isNumber(x: unknown): x is number { return typeof x === 'number'; } // todo come up with better detector function isES6Class(x: unknown): x is ((...args: any[]) => any) { return typeof x === 'function' && /^\s*class\s+/.test(x.toString()); } @injectable() export abstract class Route implements IRoute { @inject(RequestSymbol) protected readonly request!: IHttpRequest; @inject(ResponseSymbol) protected readonly response!: IHttpResponse; protected redirect(url: string): Promise; protected redirect(url: string, statusCode: number): Promise; protected redirect(url: string, html: true): Promise; protected redirect(url: string, javascript: string): Promise; protected async redirect(url: string, statusCode: boolean | number | string = StatusCode.TemporaryRedirect): Promise { this.ensureCacheControl(); this.sendProfilingData(); if (typeof statusCode === 'string') { // noinspection UnnecessaryLocalVariableJS const userCode = statusCode; const scriptBody = `window.onload = function () { ${userCode}; console.log('Redirecting...'); window.location.href = ${JSON.stringify(url)}; }`; const scriptBodyHash = sha256_base64(scriptBody) return this.profileResponse('redirect', async () => { this.response .status(StatusCode.OK) .header('Cross-Origin-Resource-Policy', 'cross-origin') .header('Cross-Origin-Opener-Policy', 'same-origin') .header('Content-Security-Policy', `script-src 'sha256-${scriptBodyHash}';`) .send(` Redirecting...

If you are not redirected, click here.

`); }); } if (statusCode === true) { return this.profileResponse('redirect', async () => { this.response .status(StatusCode.OK) .header('Cross-Origin-Resource-Policy', 'cross-origin') .send(` Redirecting...

If you are not redirected, click here.

`); }); } if (typeof statusCode !== 'number') { throw new Error(`Unexpected status code ${JSON.stringify(statusCode)}`) } this.response.header('Cross-Origin-Resource-Policy', 'cross-origin').redirect(statusCode, url); } protected async empty(statusCode: number = StatusCode.NoContent): Promise { this.ensureCacheControl(); this.sendProfilingData(); this.response .status(statusCode) .send(); } protected jsonAsync(content: Promise, outputBuilder: IOutputBuilder): Promise; protected jsonAsync(content: Promise, outputBuilder: IOutputBuilder): Promise; protected jsonAsync(content: Promise, statusCode?: StatusCode): Promise; protected jsonAsync(content: Promise, statusCode?: StatusCode): Promise; protected jsonAsync(content: Promise, Model: [Class]): Promise; protected jsonAsync(content: Promise, Model: Class): Promise; protected jsonAsync(content: Promise, statusCode: StatusCode, Model: [Class]): Promise; protected jsonAsync(content: Promise, statusCode: StatusCode, Model: Class): Promise; protected async jsonAsync(promise: Promise, ...args: unknown[]): Promise { return this.json(await promise, ...args as any); } protected json(content: readonly T[], statusCode: StatusCode, outputBuilder: IOutputBuilder): Promise; protected json(content: readonly T[], outputBuilder: IOutputBuilder): Promise; protected json(content: T, statusCode: StatusCode, outputBuilder: IOutputBuilder): Promise; protected json(content: T, outputBuilder: IOutputBuilder): Promise; protected json(content: readonly unknown[], Model: [Class]): Promise; protected json(content: unknown, Model: Class): Promise; protected json(content: readonly unknown[], statusCode: StatusCode, Model: [Class]): Promise; protected json(content: unknown, statusCode: StatusCode, Model: Class): Promise; protected json(content: readonly unknown[], statusCode?: StatusCode): Promise; protected json(content: unknown, statusCode?: StatusCode): Promise; protected async json(content: unknown, ...args: unknown[]): Promise { if (content instanceof Promise) { throw new Error(`Please use jsonAsync() for async content`); } const statusCode = isNumber(args[0]) ? args.shift() as number : StatusCode.OK; this.ensureCacheControl(); this.sendProfilingData(); this.ensureRequestMedia('application/json'); this.setHeader('Date', new Date().toUTCString()); if (typeof content === 'string' || typeof content === 'number' || typeof content === 'boolean') { return this.profileResponse('json', async () => { this.response .status(statusCode) .json(content); }); } let results = content; if (isOutputBuilder(args[0]) && Array.isArray(content)) { results = await args[0].build(content); } else if (isOutputBuilder(args[0])) { ([results] = await args[0].build([content])); } else if (!Array.isArray(content) && isES6Class(args[0])) { results = read(content, args[0]); } else if (Array.isArray(content) && Array.isArray(args[0]) && args[0].length === 1 && isES6Class(args[0][0])) { results = read(content, [args[0][0]]); } return this.profileResponse('json', async () => { this.response .status(statusCode) .json(results); }) } protected text( content: TResponse, statusCode: StatusCode | number = StatusCode.OK, ): Promise { this.ensureCacheControl(); this.sendProfilingData(); this.ensureRequestMedia('text/plain'); this.setHeader('Date', new Date().toUTCString()); return this.profileResponse('text',async () => { this.response .status(statusCode ?? 200) .type('text') .send(content) }); } protected stream( content: TResponse, size?: number, contentType = 'application/octet-stream', statusCode: StatusCode | number = StatusCode.OK, ): Promise { this.ensureCacheControl(); this.sendProfilingData(); this.ensureRequestMedia(contentType); if (size != null) { this.setHeader('Content-Length', size.toString()); } this.setHeader('Date', new Date().toUTCString()); this.response .status(statusCode ?? 200) .type(contentType) return this.profileResponse('stream', () => new Promise((resolve, reject) => { pipeline( content, this.response, (e) => e != null ? reject(e) : resolve(), ); })); } private async profileResponse(kind: string, action: (() => Promise | void)): Promise { if (!this.request.container?.isBound(ProfilingSessionSymbol)) { return action(); } const profilingSession = this.request.container.get(ProfilingSessionSymbol); return profilingSession.auto(kind, 'response', async () => action()) } // noinspection JSUnusedGlobalSymbols protected ensureRequestMedia(expected: string): void { const { accept } = this.request.headers; if (typeof accept !== 'string' || accept.indexOf('*/*') >= 0) { return; } if (accept.split(',').every(value => !value.trim().startsWith(expected))) { throw new RequestError( `Requested content-type is not supported. Default is ${expected}`, '', StatusCode.NotAcceptable); } } protected getRequestMediaPriority(contentType: string): number | null { const { accept } = this.request.headers; if (typeof accept !== 'string' || accept.trim() === '*/*') { return 0; } const priority = accept.split(',').findIndex(value => isMatchingRequestMedia(value.trim(), contentType)); return priority >= 0 ? priority : null; } protected getPreferredRequestMedia(...contentTypes: string[]): string | null { return contentTypes .map(value => ({value, priority: this.getRequestMediaPriority(value)})) .filter(({priority}) => priority != null) .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) .shift()?.value ?? null; } protected ensureCacheControl(): void { if (!this.response.headersSent) { const cacheControl = this.response.cacheControl; this.setHeader('Cache-Control', `${cacheControl?.type ?? 'no-cache'}, max-age=${cacheControl?.maxAge ?? 0}`) } } protected setHeader(name: string, value: string): this { this.response.set(name, value); return this; } protected isNewerContent(etag: string, lastModified?: Date): boolean { const ifNoneMatch = this.request.header('If-None-Match'); if (typeof ifNoneMatch === 'string') { etag = etag.endsWith('"') ? etag : JSON.stringify(etag); return ifNoneMatch.replace('W/', '') !== etag.replace('W/', ''); } const ifModifiedSince = this.request.header('If-Modified-Since'); if (ifModifiedSince) { const ts = new Date(ifModifiedSince).getTime(); return isNaN(ts) || ts > (lastModified?.getTime() ?? 0); } return true; } protected isSameContent(etag: string, lastModified?: Date): boolean { const ifMatch = this.request.header('If-Match'); if (typeof ifMatch === 'string') { etag = etag.endsWith('"') ? etag : JSON.stringify(etag); return ifMatch.replace('W/', '') === etag.replace('W/', ''); } const ifUnmodifiedSince = this.request.header('If-Unmodified-Since'); if (ifUnmodifiedSince) { const ts = new Date(ifUnmodifiedSince).getTime(); return isNaN(ts) || ts <= (lastModified?.getTime() ?? 0); } return true; } protected isNewerModel(model: ETaggable): boolean; protected isNewerModel(uid: string, lastModified: Date, version?: number): boolean; protected isNewerModel(...args: unknown[]): boolean { let uid: string, lastModified: Date, version: number if (args.length === 1) { ({uid, lastModified, version = 0} = args[0] as ETaggable); } else { ([uid, lastModified, version = 0] = args as [string, Date, number?]); } return this.isNewerContent(etag(uid, lastModified, version), lastModified); } protected isSameModel(model: ETaggable): boolean; protected isSameModel(uid: string, lastModified: Date, version?: number): boolean; protected isSameModel(...args: unknown[]): boolean { let uid: string, lastModified: Date, version: number if (args.length === 1) { ({uid, lastModified, version = 0} = args[0] as ETaggable); } else { ([uid, lastModified, version = 0] = args as [string, Date, number?]); } return this.isSameContent(etag(uid, lastModified, version), lastModified); } protected isNewerList(list: readonly ETaggable[] | Iterable): boolean { const [etag, lastModified] = etagList(list); return this.isNewerContent(etag, lastModified); } protected isSameList(list: readonly ETaggable[] | Iterable): boolean { const [etag, lastModified] = etagList(list); return this.isSameContent(etag, lastModified); } protected ensureModelETag(model: ETaggable): void; protected ensureModelETag(uid: string, lastModified: Date, version?: number): void; protected ensureModelETag(...args: unknown[]): void { let uid: string, lastModified: Date, version: number if (args.length === 1) { ({uid, lastModified, version = 0} = args[0] as ETaggable); } else { ([uid, lastModified, version = 0] = args as [string, Date, number?]); } if (!this.isSameModel(uid, lastModified, version)) { throw new RequestError(`Newer content found on server`, 'Precondition Failed', StatusCode.PreconditionFailed); } } protected setModelETag(model: ETaggable): void; protected setModelETag(uid: string, lastModified: Date, version?: number): void; protected setModelETag(...args: unknown[]): void { let uid: string, lastModified: Date, version: number if (args.length === 1) { ({uid, lastModified, version = 0} = args[0] as ETaggable); } else { ([uid, lastModified, version = 0] = args as [string, Date, number?]); } this.setContentETag(etag(uid, lastModified, version), lastModified); } protected setListETag(list: readonly ETaggable[] | Iterable): void { const [etag, lastModified] = etagList(list); this.setContentETag(etag, lastModified); } protected setContentETag(etag: string, lastModified?: Date): void { this.setHeader('ETag', etag.endsWith('"') ? etag : JSON.stringify(etag)); if (lastModified) { this.setHeader('Last-Modified', lastModified.toString()); } } protected validate(validator: IValidator, value: T, message?: string): void; protected validate(validator: IValidatorWithOptions, value: T, message?: string, options?: X): void; protected validate(validator: IValidator, value: T[], message?: string): void; protected validate(validator: IValidatorWithOptions, value: T[], message?: string, options?: X): void; protected validate(validator: IValidator, value: unknown, message?: string, options: any = {}): void { if (Array.isArray(value)) { value.forEach(v => { this.validate(validator, v); }) } let restrictExtraFields = true; if (typeof value === 'object' && value != null && hasOwnProperty(value, 'raw')) { value = value.raw; restrictExtraFields = false; } const isValid = validator.validate(value, {restrictExtraFields, ...options}); if (isValid !== true) { throw new BadRequestError((message ?? 'Errors: ') + '\n' + isValid.join('\n'), isValid); } } private sendProfilingData() { if (this.request.container?.isBound(ProfilingSessionSymbol) && process.env.VERBOSE_SERVER_TIMING === 'on') { const profilingSession = this.request.container.get(ProfilingSessionSymbol); if (profilingSession.logs.length > 0 && !this.response.headersSent) { this.setHeader('Server-Timing', serverTimingReporter(profilingSession.logs).join(',')); } } } public abstract handle(...args: any[]): Promise; } function hasOwnProperty(obj: X, prop: Y): obj is X & Record { return Object.prototype.hasOwnProperty.call(obj, prop); } function isMatchingRequestMedia(expected: string, actual: string): boolean { if (expected === '*/*') { return true; } if (expected.endsWith('*')) { return actual.startsWith(expected.substring(0, expected.length - 1)); } return actual === expected; } function isOutputBuilder(value: unknown): value is IOutputBuilder { return !!value && typeof value === 'object' && ('build' in value) && (typeof value.build === 'function'); }