diff --git a/.npmignore b/.npmignore deleted file mode 100644 index adac8ad9c..000000000 --- a/.npmignore +++ /dev/null @@ -1,3 +0,0 @@ -* -!lib/** -!.npmignore diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 4ec273f77..000000000 --- a/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -save=false - diff --git a/.travis.yml b/.travis.yml index a420ca96d..1d563701a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,14 +3,22 @@ language: node_js node_js: - "8" - "10" - - "11" - - "node" + - "12" sudo: false install: - "npm install" +cache: + npm: false + directories: + - $HOME/.npm + +before_install: + - if [[ "$TRAVIS_OS_NAME" != "windows" ]]; then npm update -g npm; fi + - if [[ "$TRAVIS_OS_NAME" = "windows" ]]; then node `npm prefix -g`/node_modules/npm/bin/npm-cli.js i -g npm@latest; fi + os: - "linux" - "osx" diff --git a/API.md b/API.md index 3b7e2994b..0d1fd57cb 100755 --- a/API.md +++ b/API.md @@ -1,4 +1,4 @@ -# v18.3.x API Reference +# v18.4.x API Reference @@ -161,6 +161,7 @@ - [`route.options.validate.params`](#route.options.validate.params) - [`route.options.validate.payload`](#route.options.validate.payload) - [`route.options.validate.query`](#route.options.validate.query) + - [`route.options.validate.state`](#route.options.validate.state) - [Request lifecycle](#request-lifecycle) - [Lifecycle methods](#lifecycle-methods) - [Lifecycle workflow](#lifecycle-workflow) @@ -201,6 +202,7 @@ - [`response.charset(charset)`](#response.charset()) - [`response.code(statusCode)`](#response.code()) - [`response.message(httpMessage)`](#response.message()) + - [`response.compressed(encoding)`](#response.compressed()) - [`response.created(uri)`](#response.created()) - [`response.encoding(encoding)`](#response.encoding()) - [`response.etag(tag, options)`](#response.etag()) @@ -792,44 +794,30 @@ The internally generated events are (identified by their `tags`): - `accept-encoding` `error` - a request received contains an invalid Accept-Encoding header. - `auth` `unauthenticated` - no authentication scheme included with the request. -- `auth` `unauthenticated` `response` `{strategy}` - the authentication strategy listed returned a - non-error response (e.g. a redirect to a login page). -- `auth` `unauthenticated` `error` `{strategy}` - the request failed to pass the listed - authentication strategy (invalid credentials). -- `auth` `unauthenticated` `missing` `{strategy}` - the request failed to pass the listed - authentication strategy (no credentials found). -- `auth` `unauthenticated` `try` `{strategy}` - the request failed to pass the listed - authentication strategy in `'try'` mode and will continue. +- `auth` `unauthenticated` `response` `{strategy}` - the authentication strategy listed returned a non-error response (e.g. a redirect to a login page). +- `auth` `unauthenticated` `error` `{strategy}` - the request failed to pass the listed authentication strategy (invalid credentials). +- `auth` `unauthenticated` `missing` `{strategy}` - the request failed to pass the listed authentication strategy (no credentials found). +- `auth` `unauthenticated` `try` `{strategy}` - the request failed to pass the listed authentication strategy in `'try'` mode and will continue. - `auth` `scope` `error` - the request authenticated but failed to meet the scope requirements. -- `auth` `entity` `user` `error` - the request authenticated but included an application entity - when a user entity was required. -- `auth` `entity` `app` `error` - the request authenticated but included a user entity when an - application entity was required. -- `handler` `error` - the route handler returned an error. Includes the execution duration and the - error message. -- `pre` `error` - a pre method was executed and returned an error. Includes the execution duration, - assignment key, and error. +- `auth` `entity` `user` `error` - the request authenticated but included an application entity when a user entity was required. +- `auth` `entity` `app` `error` - the request authenticated but included a user entity when an application entity was required. +- `handler` `error` - the route handler returned an error. Includes the execution duration and the error message. +- `pre` `error` - a pre method was executed and returned an error. Includes the execution duration, assignment key, and error. - `internal` `error` - an HTTP 500 error response was assigned to the request. - `internal` `implementation` `error` - an incorrectly implemented [lifecycle method](#lifecycle-methods). - `request` `abort` `error` - the request aborted. - `request` `closed` `error` - the request closed prematurely. - `request` `error` - the request stream emitted an error. Includes the error. -- `request` `server` `timeout` `error` - the request took too long to process by the server. - Includes the timeout configuration value and the duration. -- `state` `error` - the request included an invalid cookie or cookies. Includes the cookies and - error details. -- `state` `response` `error` - the response included an invalid cookie which prevented generating a - valid header. Includes the error. +- `request` `server` `timeout` `error` - the request took too long to process by the server. Includes the timeout configuration value and the duration. +- `state` `error` - the request included an invalid cookie or cookies. Includes the cookies and error details. +- `state` `response` `error` - the response included an invalid cookie which prevented generating a valid header. Includes the error. - `payload` `error` - failed processing the request payload. Includes the error. - `response` `error` - failed writing the response to the client. Includes the error. -- `response` `error` `close` - failed writing the response to the client due to prematurely closed - connection. -- `response` `error` `aborted` - failed writing the response to the client due to prematurely - aborted connection. +- `response` `error` `close` - failed writing the response to the client due to prematurely closed connection. +- `response` `error` `aborted` - failed writing the response to the client due to prematurely aborted connection. - `response` `error` `cleanup` - failed freeing response resources. -- `validation` `error` `{input}` - input (i.e. payload, query, params, headers) validation failed. - Includes the error. -- `validation` `response` `error` - response validation failed. Includes the error message. +- `validation` `error` `{input}` - input (i.e. payload, query, params, headers) validation failed. Includes the error. Only emitted when `failAction` is set to `'log'`. +- `validation` `response` `error` - response validation failed. Includes the error message. Only emitted when `failAction` is set to `'log'`. ##### `'response'` Event @@ -1140,7 +1128,7 @@ where each key is the cookie name and value is the configuration object. Access: read only. -An array containing the names of all configued cookies. +An array containing the names of all configured cookies. #### `server.type` @@ -2820,7 +2808,7 @@ where: Return value: a header string. Note that this utility uses the server configuration but does not change the server state. It is -provided for manual cookie formating (e.g. when headers are set manually). +provided for manual cookie formatting (e.g. when headers are set manually). ### `await server.states.parse(header)` @@ -3092,7 +3080,7 @@ The route handler function performs the main business logic of the route and set - a [lifecycle method](#lifecycle-methods). -- an object with a single property using the name of a handler type registred with the +- an object with a single property using the name of a handler type registered with the [`server.decorate()`](#server.decorate()) method. The matching property value is passed as options to the registered handler generator. @@ -3925,7 +3913,7 @@ The return value must be one of: (auth scheme only). - a promise object that resolve to any of the above values -Any error thrown by a lifecycle method will be used as the reponse object. While errors and valid +Any error thrown by a lifecycle method will be used as the response object. While errors and valid values can be returned, it is recommended to throw errors. Throwing non-error values will generate a Bad Implementation (500) error response. @@ -3946,7 +3934,7 @@ via [`h.context`](#h.context). #### Lifecycle workflow -The flow between each lifecyle step depends on the value returned by each lifecycle method as +The flow between each lifecycle step depends on the value returned by each lifecycle method as follows: - an error: @@ -4197,7 +4185,7 @@ Sets the response 'ETag' and 'Last-Modified' headers and checks for any conditio to decide if the response is going to qualify for an HTTP 304 (Not Modified). If the entity values match the request conditions, `h.entity()` returns a response object for the lifecycle method to return as its value which will set a 304 response. Otherwise, it sets the provided entity headers -and returns `undefined`. The method argumetns are: +and returns `undefined`. The method arguments are: - `options` - a required configuration object with: - `etag` - the ETag string. Required if `modified` is not present. Defaults to no header. @@ -4499,6 +4487,16 @@ Sets the HTTP status message where: Return value: the current response object. +#### `response.compressed(encoding)` + +Sets the HTTP 'content-encoding' header where: + +- `encoding` - the header value string. + +Return value: the current response object. + +Note that setting content encoding via this method does not set a 'vary' HTTP header with 'accept-encoding' value. To vary the response, use the `response.header()` method instead. + #### `response.created(uri)` Sets the HTTP status code to Created (201) and the HTTP 'Location' header where: @@ -4958,7 +4956,7 @@ The parsed request URI. Returns a [`response`](#response-object) which you can pass into the [reply interface](#response-toolkit) where: - `source` - the value to set as the source of the [reply interface](#response-toolkit), optional. -- `options` - optional object with the following optioal properties: +- `options` - optional object with the following optional properties: - `variety` - a sting name of the response type (e.g. `'file'`). - `prepare` - a function with the signature `async function(response)` used to prepare the response after it is returned by a [lifecycle method](#lifecycle-methods) such as setting a diff --git a/LICENSE.md b/LICENSE.md index e32cefd50..698a70839 100755 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ -Copyright (c) 2011-2019, Sideway Inc, and project contributors -Copyright (c) 2011-2014, Walmart -Copyright (c) 2011, Yahoo Inc. +Copyright (c) 2011-2019, Sideway Inc, and project contributors +Copyright (c) 2011-2014, Walmart +Copyright (c) 2011, Yahoo Inc. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md index 31d700db9..a27796ee5 100755 --- a/README.md +++ b/README.md @@ -1,37 +1,16 @@ - + -### Web and services application framework +# @hapi/hapi -Enterprise support plans and commercial licensing are available. Please check the -[Version Support Schedule](https://github.com/hapijs/hapi/blob/master/SUPPORT.md) for more information. +# The Simple, Secure Framework Developers Trust -**hapi** is a simple to use configuration-centric framework with built-in support for input validation, caching, -authentication, and other essential facilities for building web and services applications. **hapi** enables -developers to focus on writing reusable application logic in a highly modular and prescriptive approach. +Build powerful, scalable applications, with minimal overhead and full out-of-the-box functionality - your code, your way. -Version 18.x only supports node v8.12.0 and over. For older version of node please use hapi version 16.x. +### Visit the [hapi.dev](https://hapi.dev) Developer Portal for tutorials, documentation, and support -Development version: **18.1.x** ([release notes](https://github.com/hapijs/hapi/issues?labels=release+notes&page=1&state=closed)) -[![Build Status](https://secure.travis-ci.org/hapijs/hapi.svg?branch=master)](https://travis-ci.org/hapijs/hapi) +## Useful resources -For the latest updates, [change log](https://hapijs.com/updates), and release information visit [hapijs.com](https://hapijs.com) and follow [@hapijs](https://twitter.com/hapijs) on twitter. If you have questions, please open an issue in the -[discussion forum](https://github.com/hapijs/discuss). - -# Sponsorship - -## Featured Sponsors - -[![Auth0](https://user-images.githubusercontent.com/56631/31878562-5c64483a-b78f-11e7-92da-5a991ebb302d.png)](https://bit.ly/auth0h-rn) - -## Active Supporters - -- **CNN Digital** -- **[Contentful](https://www.contentful.com/)** -- The product development team at **Creative Artists Agency** - -## Legacy Supporters - -Past major financial support for the project was provided by: - -- [Condé Nast Technology](https://technology.condenast.com/) -- [![Lob](https://user-images.githubusercontent.com/56631/42724877-60d54714-872f-11e8-97e9-07726418f41f.png)](https://lob.com/) +- [Documentation and API](https://hapi.dev/) +- [Version status](https://hapi.dev/resources/status/#hapi) (builds, dependencies, node versions, licenses, eol) +- [Project policies](https://hapi.dev/policies/) +- [Free and commercial support options](https://hapi.dev/support/) diff --git a/SPONSORS.md b/SPONSORS.md index e4b699434..e0956bd12 100755 --- a/SPONSORS.md +++ b/SPONSORS.md @@ -2,6 +2,7 @@ - Alexander Alimovs - Alvin Lumowa +- Andreas Zeuch - Andres Garcia / Florence Healthcare - Auth0 - Benjamin Flesch / StriveWire @@ -19,6 +20,7 @@ - Dmitry Savinkov - Elba Sánchez Márquez - Eric Lanehart +- Fabian Gündel / DataWrapper.de - [gitconnected](https://gitconnected.com) - Greg Allen / First + Third - Healthline Media @@ -30,6 +32,7 @@ - Luke Bond - Mahesh Babu R - Manny Pamintuan +- Marie Clemenssy - Max Fierro - Nicklas Laine Overgaard - NZZ Visuals @@ -40,13 +43,16 @@ - Raffi Minassian - Raquel Hernandez - Robert McGuinness +- Rocky Jaiswal - Saul Maddox +- Seth Holladay - Shane Warren - Srikanth Minnam - Sven Lito - Tim Boudreau - Trey Huffine - Umut Şirin +- Viswanadha Pratap Kondoju - Yehor Sergeenko # Supporters @@ -54,7 +60,7 @@ - Adam Recvlohe - Adilson Schmitt Junior - Alexander Springer -- Andreas Zeuch +- Amit Lahav - Checkly - Chris St - Daniel Cassidy @@ -64,12 +70,10 @@ - Finnegan - Geraint Corneu - Marcos Bérgamo -- Marie Clemenssy - Nathan Buchar - Nathan Heskew - PIer Bover - Robin van der Vleuten - Sheridan Tighe - Troy Whiteley -- Viswanadha Pratap Kondoju - Wouter De Loose diff --git a/SUPPORT.md b/SUPPORT.md deleted file mode 100755 index 1dbee0eea..000000000 --- a/SUPPORT.md +++ /dev/null @@ -1,61 +0,0 @@ -# **hapi** core modules support policy - -The hapi code module and its dependencies are published under the following support rules: - -TL;DR - -Every **major** version of the [core module](https://github.com/hapijs/hapi) receives at least one -year of support from the time of publication of the first `x.0.0` release. When a new **major** -version is published, the previous **major** version gets at least 3 months of support from that -moment. There are typically one to three major releases each year and most are simple to upgrade. -Core dependecies are maintained as long as they are used by a supported core version. - -## Current versions - -- `v18.x.x` - under active support. Maintenance support until at least January 18th, 2020. -- `v17.x.x` - under maintenance support until August 1st, 2019. - -Extended paid support options is available for v16, v17, and v18 by contacting -[sales@sideway.com](mailto:sales.sideway.com). For more information visit [hapi.business](http://hapi.business). - -## Latest version - -The latest version of the `hapi` module published to [npm](https://www.npmjs.com/package/hapi) is -the currently supported version. It receives bug fixes, security patches, and new features on an -ongoing basis. - -Only the latest published module of the current **major** version is supported. For example, if the -current published version is `v10.2.3`, it is the only supported version of the `v10.x.x` branch. -If you are using an older version of `v10.x.x` such as `v10.0.4`, you must upgrade to `v10.2.3` -before opening issues and seeking support. - -## Previous **major** version - -Once a new major version is published, the previous major version goes into maintenance mode. -Versions in maintenance mode receive critical bug fixes and security patches but do not receive new -features. - -Each major version branch stays in maintenance mode for whichever is the **longer** period: -- 1 year from the day of the first publication of **this** major version, or -- 3 months from the day of the first publication of the **following** major version. - -For example, if: -- version `v9.0.0` was published on January 1st, 2010, and -- version `v10.0.0` was published on May 1st, 2010 - -Support for `v9.x.x` will end on January 1st, 2011 (one year from time of initial publication of -the `v9.0.0` release). - -However, if: -- version `v9.0.0` was published on January 1st, 2010, and -- version `v10.0.0` was published on December 1st, 2010 - -Support for `v9.x.x` will end on March 1st, 2011 (three months from time of initial publication of -the `v10.0.0` release). - -## Deprecated versions - -When a version is no longer supported, it will be marked as `deprecated` in the npm registry. You -may continue using it at your own risk, ignoring the warning messages. - -Starting with v16, you may obtain extended paid support by contacting [sales@sideway.com](mailto:sales.sideway.com). diff --git a/lib/compression.js b/lib/compression.js index 95bb544e3..8c241a48b 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -77,9 +77,14 @@ exports = module.exports = internals.Compression = class { encoding(response, length) { + if (response.settings.compressed) { + response.headers['content-encoding'] = response.settings.compressed; + return null; + } + const request = response.request; if (!request._core.settings.compression || - (length !== null && length < request._core.settings.compression.minBytes)) { + length !== null && length < request._core.settings.compression.minBytes) { return null; } @@ -95,7 +100,7 @@ exports = module.exports = internals.Compression = class { return null; } - return (request.info.acceptEncoding === 'identity' ? null : request.info.acceptEncoding); + return request.info.acceptEncoding === 'identity' ? null : request.info.acceptEncoding; } encoder(request, encoding) { diff --git a/lib/core.js b/lib/core.js index fb0e998bd..d74c5f340 100755 --- a/lib/core.js +++ b/lib/core.js @@ -77,6 +77,7 @@ exports = module.exports = internals.Core = class { this.router = new Call.Router(this.settings.router); this.phase = 'stopped'; // 'stopped', 'initializing', 'initialized', 'starting', 'started', 'stopping', 'invalid' this.sockets = null; // Track open sockets for graceful shutdown + this.actives = new WeakMap(); // Active requests being processed this.started = false; this.states = new Statehood.Definitions(this.settings.state); this.toolkit = new Toolkit(); @@ -119,7 +120,7 @@ exports = module.exports = internals.Core = class { const debug = (request, event) => { const data = event.error || event.data; - console.error('Debug:', event.tags.join(', '), (data ? '\n ' + (data.stack || (typeof data === 'object' ? Hoek.stringify(data) : data)) : '')); + console.error('Debug:', event.tags.join(', '), data ? '\n ' + (data.stack || (typeof data === 'object' ? Hoek.stringify(data) : data)) : ''); }; if (this.settings.debug.log) { @@ -409,7 +410,7 @@ exports = module.exports = internals.Core = class { this.sockets.forEach((connection) => { - if (!connection._isHapiProcessing) { + if (!this.actives.has(connection)) { connection.end(); } }); @@ -462,18 +463,18 @@ exports = module.exports = internals.Core = class { return (req, res) => { + // Create request + + const request = Request.generate(this.root, req, res, options); + // Track socket request processing state if (req.socket) { - req.socket._isHapiProcessing = true; + this.actives.set(req.socket, request); const env = { core: this, req }; res.on('finish', internals.onFinish.bind(res, env)); } - // Create request - - const request = Request.generate(this.root, req, res, options); - // Check load if (this.settings.load.sampleInterval) { @@ -502,8 +503,16 @@ exports = module.exports = internals.Core = class { this._log(['connection', 'client', 'error'], err); - if (socket.writable) { - socket.end(internals.badRequestResponse); + if (socket.readable) { + const request = this.actives.get(socket); + if (request) { + const error = Boom.badRequest(); + error.output.headers = { connection: 'close' }; + request._reply(error); + } + else { + socket.end(internals.badRequestResponse); + } } else { socket.destroy(err); @@ -523,7 +532,7 @@ exports = module.exports = internals.Core = class { const address = this.listener.address(); this.info.address = address.address; this.info.port = address.port; - this.info.uri = (this.settings.uri || (this.info.protocol + '://' + this.info.host + ':' + this.info.port)); + this.info.uri = this.settings.uri || this.info.protocol + '://' + this.info.host + ':' + this.info.port; } if (this.settings.operations.cleanStop) { @@ -579,7 +588,7 @@ exports = module.exports = internals.Core = class { } const timestamp = Date.now(); - const field = (data instanceof Error ? 'error' : 'data'); + const field = data instanceof Error ? 'error' : 'data'; let event = { timestamp, tags, [field]: data, channel }; @@ -594,7 +603,7 @@ exports = module.exports = internals.Core = class { internals.setup = function (options = {}) { - let settings = Hoek.cloneWithShallow(options, ['cache', 'listener', 'routes.bind']); + let settings = Hoek.clone(options, { shallow: ['cache', 'listener', 'routes.bind'] }); settings.routes = Config.enable(settings.routes); settings = Config.apply('server', settings); @@ -634,7 +643,7 @@ internals.onFinish = function (env) { const { core, req } = env; - req.socket._isHapiProcessing = false; + core.actives.delete(req.socket); if (!core.started) { req.socket.end(); } diff --git a/lib/headers.js b/lib/headers.js index 4480d7c16..1d39e6c0d 100755 --- a/lib/headers.js +++ b/lib/headers.js @@ -180,8 +180,13 @@ exports.unmodified = function (request) { modified: response.headers['last-modified'] }; - if (Response.unmodified(request, entity)) { + const etag = Response.unmodified(request, entity); + if (etag) { response.code(304); + + if (etag !== true) { // Override etag with incoming weak match + response.headers.etag = etag; + } } }; diff --git a/lib/methods.js b/lib/methods.js index 528a57918..794b8c787 100755 --- a/lib/methods.js +++ b/lib/methods.js @@ -43,7 +43,7 @@ exports = module.exports = internals.Methods = class { options = Config.apply('method', options, name); - const settings = Hoek.cloneWithShallow(options, ['bind']); + const settings = Hoek.clone(options, { shallow: ['bind'] }); settings.generateKey = settings.generateKey || internals.generateKey; const bind = settings.bind || realm.settings.bind || null; diff --git a/lib/request.js b/lib/request.js index aa056b0b3..2e7d2c71f 100755 --- a/lib/request.js +++ b/lib/request.js @@ -35,7 +35,7 @@ exports = module.exports = internals.Request = class { this._states = {}; this._urlError = null; - this.app = (options.app ? Object.assign({}, options.app) : {}); // Place for application-specific state without conflicts with hapi, should not be used by plugins (shallow cloned) + this.app = options.app ? Object.assign({}, options.app) : {}; // Place for application-specific state without conflicts with hapi, should not be used by plugins (shallow cloned) this.headers = req.headers; this.info = internals.info(this._core, req); this.jsonp = null; @@ -47,7 +47,7 @@ exports = module.exports = internals.Request = class { this.paramsArray = null; // Array of path parameters in path order this.path = null; this.payload = null; - this.plugins = (options.plugins ? Object.assign({}, options.plugins) : {}); // Place for plugins to store state without conflicts with hapi, should be namespaced using plugin name (shallow cloned) + this.plugins = options.plugins ? Object.assign({}, options.plugins) : {}; // Place for plugins to store state without conflicts with hapi, should be namespaced using plugin name (shallow cloned) this.pre = {}; // Pre raw values this.preResponses = {}; // Pre response values this.raw = { req, res }; @@ -132,9 +132,9 @@ exports = module.exports = internals.Request = class { _setUrl(url, stripTrailingSlash) { - const base = (url[0] === '/' ? `${this._core.info.protocol}://${this.info.host || `${this._core.info.host}:${this._core.info.port}`}` : undefined); + const base = url[0] === '/' ? `${this._core.info.protocol}://${this.info.host || `${this._core.info.host}:${this._core.info.port}`}` : ''; - url = new Url.URL(url, base); + url = new Url.URL(base + url); // Apply path modifications @@ -496,7 +496,13 @@ exports = module.exports = internals.Request = class { return null; } - return (this._events.hasListeners('finish') || this._events.hasListeners('peek') ? new Response.Peek(this._events) : null); + if (this._events.hasListeners('peek') || + this._events.hasListeners('finish')) { + + return new Response.Peek(this._events); + } + + return null; } log(tags, data) { @@ -517,7 +523,7 @@ exports = module.exports = internals.Request = class { } const timestamp = Date.now(); - const field = (data instanceof Error ? 'error' : 'data'); + const field = data instanceof Error ? 'error' : 'data'; let event = [this, { request: this.info.id, timestamp, tags, [field]: data, channel }]; if (typeof data === 'function') { diff --git a/lib/response.js b/lib/response.js index b85a791e0..5abf775e5 100755 --- a/lib/response.js +++ b/lib/response.js @@ -38,19 +38,19 @@ exports = module.exports = internals.Response = class { this.variety = null; this.settings = { - encoding: 'utf8', charset: 'utf-8', // '-' required by IANA - ttl: null, - stringify: null, // JSON.stringify options + compressed: null, + encoding: 'utf8', + message: null, passThrough: true, - varyEtag: false, - message: null + stringify: null, // JSON.stringify options + ttl: null, + varyEtag: false }; this._events = null; this._payload = null; // Readable stream this._error = null; // The boom object when created from an error (used for logging) - this._contentEncoding = null; // Set during transmit this._contentType = null; // Used if no explicit content-type is set and type is known this._takeover = false; this._statusCode = false; // true when code() called @@ -98,7 +98,7 @@ exports = module.exports = internals.Response = class { this.variety = 'buffer'; this._contentType = 'application/octet-stream'; } - else if (source instanceof Stream) { + else if (Streams.isStream(source)) { this.variety = 'stream'; this._contentType = 'application/octet-stream'; } @@ -154,7 +154,7 @@ exports = module.exports = internals.Response = class { const override = options.override !== false; const duplicate = options.duplicate !== false; - if ((!append && override) || + if (!append && override || !this.headers[key]) { this.headers[key] = value; @@ -210,12 +210,12 @@ exports = module.exports = internals.Response = class { return { etag: (options.weak ? 'W/' : '') + '"' + tag + '"', - vary: (options.vary !== false && !options.weak), // vary defaults to true + vary: options.vary !== false && !options.weak, // vary defaults to true modified: options.modified }; } - static unmodified(request, options) { + static unmodified(request, entity) { if (request.method !== 'get' && request.method !== 'head') { @@ -225,22 +225,31 @@ exports = module.exports = internals.Response = class { // Strong verifier - if (options.etag && + if (entity.etag && request.headers['if-none-match']) { const ifNoneMatch = request.headers['if-none-match'].split(/\s*,\s*/); for (const etag of ifNoneMatch) { - if (etag === options.etag) { + + // Compare tags (https://tools.ietf.org/html/rfc7232#section-2.3.2) + + if (etag === entity.etag) { // Strong comparison return true; } - if (options.vary) { - const etagBase = options.etag.slice(0, -1); - const encoders = request._core.compression.encodings; - for (const encoder of encoders) { - if (etag === etagBase + `-${encoder}"`) { - return true; - } + if (!entity.vary) { + continue; + } + + if (etag === `W/${entity.etag}`) { // Weak comparison + return etag; + } + + const etagBase = entity.etag.slice(0, -1); + const encoders = request._core.compression.encodings; + for (const encoder of encoders) { + if (etag === etagBase + `-${encoder}"`) { + return true; } } } @@ -250,7 +259,7 @@ exports = module.exports = internals.Response = class { // Weak verifier - if (!options.modified) { + if (!entity.modified) { return false; } @@ -264,7 +273,7 @@ exports = module.exports = internals.Response = class { return false; } - const lastModified = internals.parseDate(options.modified); + const lastModified = internals.parseDate(entity.modified); if (!lastModified) { return false; } @@ -301,6 +310,13 @@ exports = module.exports = internals.Response = class { return this; } + compressed(encoding) { + + Hoek.assert(encoding && typeof encoding === 'string', 'Invalid content-encoding'); + this.settings.compressed = encoding; + return this; + } + replacer(method) { this.settings.stringify = this.settings.stringify || {}; @@ -331,7 +347,7 @@ exports = module.exports = internals.Response = class { passThrough(enabled) { - this.settings.passThrough = (enabled !== false); // Defaults to true + this.settings.passThrough = enabled !== false; // Defaults to true return this; } @@ -529,7 +545,7 @@ exports = module.exports = internals.Response = class { // Stream source - if (source instanceof Stream) { + if (Streams.isStream(source)) { if (typeof source._read !== 'function') { throw Boom.badImplementation('Stream must have a readable interface'); } @@ -544,7 +560,7 @@ exports = module.exports = internals.Response = class { // Plain source (non string or null) - const jsonify = (this.variety === 'plain' && source !== null && typeof source !== 'string'); + const jsonify = this.variety === 'plain' && source !== null && typeof source !== 'string'; if (!jsonify && this.settings.stringify) { @@ -591,7 +607,13 @@ exports = module.exports = internals.Response = class { return null; } - return (this._events.hasListeners('finish') || this._events.hasListeners('peek') ? new internals.Response.Peek(this._events) : null); + if (this._events.hasListeners('peek') || + this._events.hasListeners('finish')) { + + return new internals.Response.Peek(this._events); + } + + return null; } _close(request) { @@ -607,14 +629,14 @@ exports = module.exports = internals.Response = class { } const stream = this._payload || this.source; - if (stream instanceof Stream) { + if (Streams.isStream(stream)) { internals.Response.drain(stream); } } _isPayloadSupported() { - return (this.request.method !== 'head' && this.statusCode !== 304 && this.statusCode !== 204); + return this.request.method !== 'head' && this.statusCode !== 304 && this.statusCode !== 204; } static drain(stream) { diff --git a/lib/route.js b/lib/route.js index 953df5624..627d5c4d8 100755 --- a/lib/route.js +++ b/lib/route.js @@ -492,7 +492,7 @@ internals.config = function (chain) { let config = chain[0]; for (const item of chain) { - config = Hoek.applyToDefaultsWithShallow(config, item, ['bind', 'validate.headers', 'validate.payload', 'validate.params', 'validate.query', 'validate.state']); + config = Hoek.applyToDefaults(config, item, { shallow: ['bind', 'validate.headers', 'validate.payload', 'validate.params', 'validate.query', 'validate.state'] }); } return config; diff --git a/lib/streams.js b/lib/streams.js index 7b9dc4168..4bad93621 100755 --- a/lib/streams.js +++ b/lib/streams.js @@ -8,6 +8,14 @@ const internals = { }; +exports.isStream = function (stream) { + + return stream && + typeof stream === 'object' && + typeof stream.pipe === 'function'; +}; + + exports.drain = function (stream) { const team = new Teamwork(); @@ -16,6 +24,7 @@ exports.drain = function (stream) { stream.on('readable', internals.read); stream.on('error', internals.end); stream.on('end', internals.end); + stream.on('close', internals.end); return team.work; }; @@ -23,7 +32,7 @@ exports.drain = function (stream) { internals.read = function () { - this.read(); + while (this.read()) { } }; @@ -32,6 +41,7 @@ internals.end = function () { this.removeListener('readable', internals.read); this.removeListener('error', internals.end); this.removeListener('end', internals.end); + this.removeListener('close', internals.end); this[internals.team].attend(); }; diff --git a/lib/transmit.js b/lib/transmit.js index cbf5a4d7d..56c96af94 100755 --- a/lib/transmit.js +++ b/lib/transmit.js @@ -19,11 +19,13 @@ const internals = {}; exports.send = async function (request) { const response = request.response; - if (response.isBoom) { - return internals.fail(request, response); - } try { + if (response.isBoom) { + await internals.fail(request, response); + return; + } + await internals.marshal(request); await internals.transmit(response); } @@ -45,7 +47,7 @@ internals.marshal = async function (request) { internals.fail = async function (request, boom) { - const response = internals.error(boom, request); + const response = internals.error(request, boom); request.response = response; // Not using request._setResponse() to avoid double log try { @@ -69,7 +71,7 @@ internals.fail = async function (request, boom) { }; -internals.error = function (boom, request) { +internals.error = function (request, boom) { const error = boom.output; const response = new Response(error.payload, request); @@ -88,14 +90,14 @@ internals.transmit = function (response) { // Pipes const encoding = request._core.compression.encoding(response, length); - const ranger = (encoding ? null : internals.range(response, length)); + const ranger = encoding ? null : internals.range(response, length); const compressor = internals.encoding(response, encoding); // Connection: close const isInjection = Shot.isInjection(request.raw.req); if (!(isInjection || request._core.started) || - (request._isPayloadPending && !request.raw.req._readableState.ended)) { + request._isPayloadPending && !request.raw.req._readableState.ended) { response._header('connection', 'close'); } @@ -292,7 +294,7 @@ internals.end = function (env, event, err) { } err = err || new Boom(`Request ${event}`, { statusCode: request.route.settings.response.disconnectStatusCode }); - const error = internals.error(Boom.boomify(err), request); + const error = internals.error(request, Boom.boomify(err)); request._setResponse(error); if (request.raw.res[Config.symbol]) { diff --git a/lib/validation.js b/lib/validation.js index 0bdfa9610..f83550384 100755 --- a/lib/validation.js +++ b/lib/validation.js @@ -10,16 +10,33 @@ const internals = {}; exports.compile = function (rule) { - // null, undefined, true - anything allowed // false - nothing allowed + + if (rule === false) { + return Joi.object({}).allow(null); + } + + // Custom function + + if (typeof rule === 'function') { + return rule; + } + + // null, undefined, true - anything allowed + + if (!rule || // false tested above + rule === true) { + + return null; + } + // {...} - ... allowed - return (rule === false) ? - Joi.object({}).allow(null) : - (typeof rule === 'function' ? - rule : - !rule || rule === true ? null : Joi.compile(rule)); // false tested earlier + if (typeof rule.validate === 'function') { + return rule; + } + return Joi.compile(rule); }; @@ -83,7 +100,7 @@ internals.input = async function (source, request) { const schema = request.route.settings.validate[source]; const bind = request.route.settings.bind; - var value = await (typeof schema !== 'function' ? Joi.validate(request[source], schema, localOptions) : schema.call(bind, request[source], localOptions)); + var value = await (typeof schema !== 'function' ? internals.validate(request[source], schema, localOptions) : schema.call(bind, request[source], localOptions)); return; } catch (err) { @@ -174,7 +191,7 @@ exports.response = async function (request) { let value; if (typeof schema !== 'function') { - value = await Joi.validate(source, schema, localOptions); + value = await internals.validate(source, schema, localOptions); } else { value = await schema(source, localOptions); @@ -196,3 +213,13 @@ exports.response = async function (request) { return request._core.toolkit.failAction(request, request.route.settings.response.failAction, err, { tags: ['validation', 'response', 'error'] }); } }; + + +internals.validate = function (value, schema, options) { + + if (typeof schema.validateAsync === 'function') { + return schema.validateAsync(value, options); + } + + return schema.validate(value, options); +}; diff --git a/package.json b/package.json old mode 100644 new mode 100755 index a2c5f8cdd..63e9ffec1 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@hapi/hapi", "description": "HTTP Server framework", "homepage": "https://hapijs.com", - "version": "18.3.1", + "version": "18.4.1", "repository": "git://github.com/hapijs/hapi", "main": "lib/index.js", "keywords": [ @@ -11,38 +11,42 @@ "api", "web" ], + "files": [ + "lib" + ], "dependencies": { - "@hapi/accept": "3.x.x", - "@hapi/ammo": "3.x.x", + "@hapi/accept": "^3.2.4", + "@hapi/ammo": "^3.1.2", "@hapi/boom": "7.x.x", "@hapi/bounce": "1.x.x", - "@hapi/call": "5.x.x", + "@hapi/call": "^5.1.3", "@hapi/catbox": "10.x.x", "@hapi/catbox-memory": "4.x.x", "@hapi/heavy": "6.x.x", - "@hapi/hoek": "6.x.x", + "@hapi/hoek": "8.x.x", "@hapi/joi": "15.x.x", "@hapi/mimos": "4.x.x", "@hapi/podium": "3.x.x", "@hapi/shot": "4.x.x", "@hapi/somever": "2.x.x", "@hapi/statehood": "6.x.x", - "@hapi/subtext": "6.x.x", + "@hapi/subtext": "^6.1.3", "@hapi/teamwork": "3.x.x", "@hapi/topo": "3.x.x" }, "devDependencies": { - "@hapi/code": "5.x.x", + "@hapi/code": "6.x.x", "@hapi/inert": "5.x.x", - "@hapi/lab": "18.x.x", + "@hapi/joi-next-test": "npm:@hapi/joi@16.x.x", + "@hapi/lab": "20.x.x", "@hapi/wreck": "15.x.x", "@hapi/vision": "5.x.x", "handlebars": "4.x.x" }, "scripts": { - "test": "lab -a @hapi/code -t 100 -L -m 3000", - "test-tap": "lab -a @hapi/code -r tap -o tests.tap -m 3000", - "test-cov-html": "lab -a @hapi/code -r html -o coverage.html -m 3000" + "test": "lab -a @hapi/code -t 100 -L -m 5000", + "test-tap": "lab -a @hapi/code -r tap -o tests.tap -m 5000", + "test-cov-html": "lab -a @hapi/code -r html -o coverage.html -m 5000" }, "license": "BSD-3-Clause" } diff --git a/test/core.js b/test/core.js index 1688130ad..2938c18cb 100755 --- a/test/core.js +++ b/test/core.js @@ -7,6 +7,7 @@ const Https = require('https'); const Net = require('net'); const Os = require('os'); const Path = require('path'); +const Stream = require('stream'); const TLS = require('tls'); const Boom = require('@hapi/boom'); @@ -765,6 +766,7 @@ describe('Core', () => { const socket1 = await internals.socket(server, 'tls'); const socket2 = await internals.socket(server, 'tls'); + await Hoek.wait(50); const count1 = await internals.countConnections(server); expect(count1).to.equal(2); expect(server._core.sockets.size).to.equal(2); @@ -829,11 +831,11 @@ describe('Core', () => { const count1 = await internals.countConnections(server); expect(count1).to.equal(1); - setTimeout(() => socket.end(), 50); + setTimeout(() => socket.end(), 100); const timer = new Hoek.Bench(); - await server.stop({ timeout: 200 }); - expect(timer.elapsed()).to.be.below(150); + await server.stop({ timeout: 400 }); + expect(timer.elapsed()).to.be.below(300); }); it('immediately destroys idle keep-alive connections', async () => { @@ -1186,6 +1188,58 @@ describe('Core', () => { expect(res.request.app.key).to.equal('value'); }); + it('returns the request object for POST', async () => { + + const payload = { foo:true }; + const handler = (request) => { + + return request.payload; + }; + + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', handler }); + + const res = await server.inject({ method: 'POST', url: '/', payload }); + expect(res.statusCode).to.equal(200); + expect(JSON.parse(res.payload)).to.equal(payload); + }); + + it('returns the request string for POST', async () => { + + const payload = JSON.stringify({ foo:true }); + const handler = (request) => { + + return request.payload; + }; + + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', handler }); + + const res = await server.inject({ method: 'POST', url: '/', payload }); + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal(payload); + }); + + it('returns the request stream for POST', async () => { + + const param = { foo:true }; + const payload = new Stream.Readable(); + payload.push(JSON.stringify(param)); + payload.push(null); + + const handler = (request) => { + + return request.payload; + }; + + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', handler }); + + const res = await server.inject({ method: 'POST', url: '/', payload }); + expect(res.statusCode).to.equal(200); + expect(JSON.parse(res.payload)).to.equal(param); + }); + it('can set a client remoteAddress', async () => { const server = Hapi.server(); diff --git a/test/payload.js b/test/payload.js index f53aba681..a9d22ca08 100755 --- a/test/payload.js +++ b/test/payload.js @@ -110,14 +110,34 @@ describe('Payload', () => { const payload = '{"x":"1","y":"2","z":"3"}'; - const handler = (request) => { + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', options: { handler: () => null, payload: { maxBytes: 10 } } }); - expect(request.payload.toString()).to.equal(payload); - return request.payload; - }; + const res = await server.inject({ method: 'POST', url: '/', payload, headers: { 'content-length': payload.length } }); + expect(res.statusCode).to.equal(413); + expect(res.result).to.exist(); + expect(res.result.message).to.equal('Payload content length greater than maximum allowed: 10'); + }); + + it('errors when payload too big (implicit length)', async () => { + + const payload = '{"x":"1","y":"2","z":"3"}'; const server = Hapi.server(); - server.route({ method: 'POST', path: '/', options: { handler, payload: { maxBytes: 10 } } }); + server.route({ method: 'POST', path: '/', options: { handler: () => null, payload: { maxBytes: 10 } } }); + + const res = await server.inject({ method: 'POST', url: '/', payload }); + expect(res.statusCode).to.equal(413); + expect(res.result).to.exist(); + expect(res.result.message).to.equal('Payload content length greater than maximum allowed: 10'); + }); + + it('errors when payload too big (file)', async () => { + + const payload = '{"x":"1","y":"2","z":"3"}'; + + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', options: { handler: () => null, payload: { output: 'file', maxBytes: 10 } } }); const res = await server.inject({ method: 'POST', url: '/', payload, headers: { 'content-length': payload.length } }); expect(res.statusCode).to.equal(413); @@ -125,6 +145,19 @@ describe('Payload', () => { expect(res.result.message).to.equal('Payload content length greater than maximum allowed: 10'); }); + it('errors when payload too big (file implicit length)', async () => { + + const payload = '{"x":"1","y":"2","z":"3"}'; + + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', options: { handler: () => null, payload: { output: 'file', maxBytes: 10 } } }); + + const res = await server.inject({ method: 'POST', url: '/', payload }); + expect(res.statusCode).to.equal(413); + expect(res.result).to.exist(); + expect(res.result.message).to.equal('Payload content length greater than maximum allowed: 10'); + }); + it('errors when payload contains prototype poisoning', async () => { const server = Hapi.server(); @@ -235,6 +268,28 @@ describe('Payload', () => { expect(res.result).to.equal(payload); }); + it('peeks at unparsed data (finish only)', async () => { + + let peeked = false; + const ext = (request, h) => { + + request.events.once('finish', () => { + + peeked = true; + }); + + return h.continue; + }; + + const server = Hapi.server(); + server.ext('onRequest', ext); + server.route({ method: 'POST', path: '/', options: { handler: () => null, payload: { parse: false } } }); + + const payload = '0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789'; + await server.inject({ method: 'POST', url: '/', payload }); + expect(peeked).to.be.true(); + }); + it('handles gzipped payload', async () => { const message = { 'msg': 'This message is going to be gzipped.' }; diff --git a/test/request.js b/test/request.js index 87a441a6f..0bad46c12 100755 --- a/test/request.js +++ b/test/request.js @@ -1250,6 +1250,27 @@ describe('Request', () => { await server.stop(); expect(hostname).to.equal('host.com'); }); + + it('handles url starting with multiple /', async () => { + + const server = Hapi.server(); + server.route({ + method: 'GET', + path: '/{p*}', + handler: (request) => { + + return { + p: request.params.p, + path: request.path, + hostname: request.info.hostname.toLowerCase() // Lowercase for OSX tests + }; + } + }); + + const res = await server.inject('//path'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal({ p: '/path', path: '//path', hostname: server.info.host.toLowerCase() }); + }); }); describe('_tap()', () => { @@ -1468,7 +1489,7 @@ describe('Request', () => { expect(args[0]).to.equal('Debug:'); expect(args[1]).to.equal('implementation'); - expect(args[2]).to.equal('\n [Cannot display object: Converting circular structure to JSON]'); + expect(args[2]).to.match(/Cannot display object: Converting circular structure to JSON/); console.error = orig; resolve(); }; @@ -1759,6 +1780,103 @@ describe('Request', () => { expect(res.statusCode).to.equal(200); }); + it('creates null response when request is aborted while draining payload', async () => { + + const server = Hapi.server({ routes: { timeout: { server: false } } }); + await server.start(); + + const log = server.events.once('response'); + const ready = new Promise((resolve) => { + + server.ext('onRequest', (request, h) => { + + resolve(); + return h.continue; + }); + }); + + const req = Http.request({ + hostname: 'localhost', + port: server.info.port, + method: 'GET', + headers: { 'content-length': 42 } + }); + req.on('error', Hoek.ignore); + req.flushHeaders(); + + await ready; + req.abort(); + const [request] = await log; + + expect(request.response).to.equal(null); + + await server.stop({ timeout: 1 }); + }); + + it('returns an unlogged bad request error when parser fails before request is setup', async () => { + + const server = Hapi.server({ routes: { timeout: { server: false } } }); + await server.start(); + + let responseCount = 0; + server.events.on('response', () => { + + responseCount += 1; + }); + + const client = Net.connect(server.info.port); + const clientEnded = new Promise((resolve, reject) => { + + let response = ''; + client.on('data', (chunk) => { + + response = response + chunk.toString(); + }); + + client.on('end', () => resolve(response)); + client.on('error', reject); + }); + + await new Promise((resolve) => client.on('connect', resolve)); + client.write('hello\n\r'); + + const clientResponse = await clientEnded; + expect(clientResponse).to.contain('400 Bad Request'); + expect(responseCount).to.equal(0); + + await server.stop({ timeout: 1 }); + }); + + it('returns a logged bad request error when parser fails after request is setup', async () => { + + const server = Hapi.server({ routes: { timeout: { server: false } } }); + await server.start(); + + const log = server.events.once('response'); + const client = Net.connect(server.info.port); + const clientEnded = new Promise((resolve, reject) => { + + let response = ''; + client.on('data', (chunk) => { + + response = response + chunk.toString(); + }); + + client.on('end', () => resolve(response)); + client.on('error', reject); + }); + + await new Promise((resolve) => client.on('connect', resolve)); + client.write('GET / HTTP/1.1\nHost: test\nContent-Length: 0\n\n\ninvalid data'); + + const [request] = await log; + expect(request.response.statusCode).to.equal(400); + const clientResponse = await clientEnded; + expect(clientResponse).to.contain('400 Bad Request'); + + await server.stop({ timeout: 1 }); + }); + it('does not return an error when server is responding when the timeout occurs', async () => { let ended = false; @@ -1847,7 +1965,7 @@ describe('Request', () => { it('handles race condition between equal client and server timeouts', async () => { - const server = Hapi.server({ routes: { timeout: { server: 50 }, payload: { timeout: 50 } } }); + const server = Hapi.server({ routes: { timeout: { server: 100 }, payload: { timeout: 100 } } }); server.route({ method: 'POST', path: '/timeout', options: { handler: Hoek.block } }); await server.start(); @@ -1865,7 +1983,7 @@ describe('Request', () => { const req = Http.request(options, (res) => { expect([503, 408]).to.contain(res.statusCode); - expect(timer.elapsed()).to.be.at.least(45); + expect(timer.elapsed()).to.be.at.least(80); resolve(); }); @@ -1875,7 +1993,7 @@ describe('Request', () => { }); req.write('\n'); - await Hoek.wait(100); + await Hoek.wait(200); req.end(); }); diff --git a/test/response.js b/test/response.js index 1b2bf5d63..2ca30eb8e 100755 --- a/test/response.js +++ b/test/response.js @@ -729,6 +729,35 @@ describe('Response', () => { }); }); + describe('compressed()', () => { + + it('errors on missing encoding', async () => { + + const handler = (request, h) => { + + return h.response('x').compressed(); + }; + + const server = Hapi.server({ debug: false }); + server.route({ method: 'GET', path: '/', handler }); + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + }); + + it('errors on invalid encoding', async () => { + + const handler = (request, h) => { + + return h.response('x').compressed(123); + }; + + const server = Hapi.server({ debug: false }); + server.route({ method: 'GET', path: '/', handler }); + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + }); + }); + describe('spaces()', () => { it('errors when called on wrong type', async () => { @@ -1291,6 +1320,31 @@ describe('Response', () => { expect(output).to.equal('1234567890!'); }); + it('peeks into the response stream (finish only)', async () => { + + const server = Hapi.server(); + + let output = false; + server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + + const response = h.response('1234567890'); + + response.events.once('finish', () => { + + output = true; + }); + + return response; + } + }); + + await server.inject('/'); + expect(output).to.be.true(); + }); + it('peeks into the response stream (empty)', async () => { const server = Hapi.server(); diff --git a/test/toolkit.js b/test/toolkit.js index 871959516..3643a22d6 100755 --- a/test/toolkit.js +++ b/test/toolkit.js @@ -585,6 +585,12 @@ describe('Toolkit', () => { expect(res2.headers.etag).to.equal('"abc"'); expect(res2.headers['cache-control']).to.equal('max-age=5, must-revalidate'); expect(count).to.equal(1); + + const res3 = await server.inject({ url: '/', headers: { 'if-none-match': 'W/"abc"' } }); + expect(res3.statusCode).to.equal(304); + expect(res3.headers.etag).to.equal('W/"abc"'); + expect(res3.headers['cache-control']).to.equal('max-age=5, must-revalidate'); + expect(count).to.equal(1); }); it('leaves etag header when vary is false', async () => { diff --git a/test/transmit.js b/test/transmit.js index ec66a46bb..16174512a 100755 --- a/test/transmit.js +++ b/test/transmit.js @@ -27,6 +27,33 @@ const expect = Code.expect; describe('transmission', () => { + describe('send()', () => { + + it('handlers invalid headers in error', async () => { + + const server = Hapi.server(); + + const handler = (request, h) => { + + const error = Boom.badRequest(); + error.output.headers.invalid = '\u1000'; + throw error; + }; + + server.route({ method: 'GET', path: '/', handler }); + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + }); + + it('handles invalid headers in redirect', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: (request, h) => h.redirect('/bad/path/\n') }); + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + }); + }); + describe('marshal()', () => { it('returns valid http date responses in last-modified header', async () => { @@ -171,62 +198,94 @@ describe('transmission', () => { expect(res2.headers['last-modified']).to.exist(); }); + it('returns a 200 when the request has if-modified-since and the response has been modified since (less)', async () => { + + const server = Hapi.server(); + await server.register(Inert); + server.route({ method: 'GET', path: '/file', handler: { file: __dirname + '/../package.json' } }); + + const res1 = await server.inject('/file'); + const last = new Date(Date.parse(res1.headers['last-modified']) - 1000); + const res2 = await server.inject({ url: '/file', headers: { 'if-modified-since': last.toUTCString() } }); + expect(res2.statusCode).to.equal(200); + expect(res2.headers['content-length']).to.exist(); + expect(res2.headers.etag).to.exist(); + expect(res2.headers['last-modified']).to.exist(); + }); + it('matches etag with content-encoding', async () => { const server = Hapi.server({ compression: { minBytes: 1 } }); await server.register(Inert); server.route({ method: 'GET', path: '/', handler: { file: __dirname + '/../package.json' } }); - // Initial request - no etag + // Request const res1 = await server.inject('/'); expect(res1.statusCode).to.equal(200); + expect(res1.headers.etag).to.exist(); + expect(res1.headers.etag).to.not.contain('-'); - // Second request - etag - - const res2 = await server.inject('/'); - expect(res2.statusCode).to.equal(200); - expect(res2.headers.etag).to.exist(); - expect(res2.headers.etag).to.not.contain('-'); - - const baseTag = res2.headers.etag.slice(0, -1); + const baseTag = res1.headers.etag.slice(0, -1); const gzipTag = baseTag + '-gzip"'; // Conditional request - const res3 = await server.inject({ url: '/', headers: { 'if-none-match': res2.headers.etag } }); - expect(res3.statusCode).to.equal(304); - expect(res3.headers.etag).to.equal(res2.headers.etag); + const res2 = await server.inject({ url: '/', headers: { 'if-none-match': res1.headers.etag } }); + expect(res2.statusCode).to.equal(304); + expect(res2.headers.etag).to.equal(res1.headers.etag); // Conditional request with accept-encoding - const res4 = await server.inject({ url: '/', headers: { 'if-none-match': res2.headers.etag, 'accept-encoding': 'gzip' } }); - expect(res4.statusCode).to.equal(304); - expect(res4.headers.etag).to.equal(gzipTag); + const res3 = await server.inject({ url: '/', headers: { 'if-none-match': res1.headers.etag, 'accept-encoding': 'gzip' } }); + expect(res3.statusCode).to.equal(304); + expect(res3.headers.etag).to.equal(gzipTag); // Conditional request with vary etag - const res5 = await server.inject({ url: '/', headers: { 'if-none-match': res4.headers.etag, 'accept-encoding': 'gzip' } }); - expect(res5.statusCode).to.equal(304); - expect(res5.headers.etag).to.equal(gzipTag); + const res4 = await server.inject({ url: '/', headers: { 'if-none-match': res3.headers.etag, 'accept-encoding': 'gzip' } }); + expect(res4.statusCode).to.equal(304); + expect(res4.headers.etag).to.equal(gzipTag); // Request with accept-encoding (gzip) - const res6 = await server.inject({ url: '/', headers: { 'accept-encoding': 'gzip' } }); - expect(res6.statusCode).to.equal(200); - expect(res6.headers.etag).to.equal(gzipTag); + const res5 = await server.inject({ url: '/', headers: { 'accept-encoding': 'gzip' } }); + expect(res5.statusCode).to.equal(200); + expect(res5.headers.etag).to.equal(gzipTag); // Request with accept-encoding (deflate) - const res7 = await server.inject({ url: '/', headers: { 'accept-encoding': 'deflate' } }); - expect(res7.statusCode).to.equal(200); - expect(res7.headers.etag).to.equal(baseTag + '-deflate"'); + const res6 = await server.inject({ url: '/', headers: { 'accept-encoding': 'deflate' } }); + expect(res6.statusCode).to.equal(200); + expect(res6.headers.etag).to.equal(baseTag + '-deflate"'); // Conditional request with accept-encoding (gzip) - const res8 = await server.inject({ url: '/', headers: { 'if-none-match': res7.headers.etag, 'accept-encoding': 'gzip' } }); - expect(res8.statusCode).to.equal(304); - expect(res8.headers.etag).to.equal(gzipTag); + const res7 = await server.inject({ url: '/', headers: { 'if-none-match': res6.headers.etag, 'accept-encoding': 'gzip' } }); + expect(res7.statusCode).to.equal(304); + expect(res7.headers.etag).to.equal(gzipTag); + }); + + it('matches etag with weak designator', async () => { + + const server = Hapi.server({ compression: { minBytes: 1 } }); + await server.register(Inert); + server.route({ method: 'GET', path: '/', handler: { file: __dirname + '/../package.json' } }); + + // Fetch etag + + const res1 = await server.inject('/'); + expect(res1.statusCode).to.equal(200); + expect(res1.headers.etag).to.exist(); + expect(res1.headers.etag).to.not.contain('W/"'); + + const weakEtag = `W/${res1.headers.etag}`; + + // Conditional request + + const res2 = await server.inject({ url: '/', headers: { 'if-none-match': weakEtag } }); + expect(res2.statusCode).to.equal(304); + expect(res2.headers.etag).to.equal(weakEtag); }); it('returns 304 when manually set to 304', async () => { @@ -718,7 +777,7 @@ describe('transmission', () => { it('returns a plain file when compression disabled', async () => { - const server = Hapi.server({ compression: { minBytes: 1 }, routes: { files: { relativeTo: __dirname } }, compression: false }); + const server = Hapi.server({ routes: { files: { relativeTo: __dirname } }, compression: false }); await server.register(Inert); server.route({ method: 'GET', path: '/file', handler: (request, h) => h.file(__dirname + '/../package.json') }); @@ -1407,6 +1466,7 @@ describe('transmission', () => { port: server.info.port, method: 'GET' }); + clientRequest.on('error', () => { /* NOP */ }); clientRequest.end(); @@ -1416,13 +1476,29 @@ describe('transmission', () => { it('changes etag when content-encoding set manually', async () => { + const payload = new Array(1000).fill('x').join(); const server = Hapi.server(); - server.route({ method: 'GET', path: '/', handler: (request, h) => h.response('x').header('content-encoding', 'gzip').etag('abc') }); + server.route({ method: 'GET', path: '/', handler: (request, h) => h.response(payload).header('content-encoding', 'gzip').etag('abc') }); - const res = await server.inject('/'); + const res = await server.inject({ url: '/', headers: { 'accept-encoding': 'gzip' } }); expect(res.statusCode).to.equal(200); expect(res.headers.etag).to.exist(); expect(res.headers.etag).to.match(/-gzip"$/); + expect(res.headers.vary).to.equal('accept-encoding'); + }); + + it('changes etag without vary when content-encoding set via compressed', async () => { + + const payload = new Array(1000).fill('x').join(); + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: (request, h) => h.response(payload).compressed('gzip').etag('abc') }); + + const res = await server.inject({ url: '/', headers: { 'accept-encoding': 'gzip' } }); + expect(res.statusCode).to.equal(200); + expect(res.headers.etag).to.exist(); + expect(res.headers.etag).to.equal('"abc-gzip"'); + expect(res.headers['content-encoding']).to.equal('gzip'); + expect(res.headers.vary).to.not.exist(); }); it('head request retains content-length header', async () => { diff --git a/test/validation.js b/test/validation.js index 93a4953c0..7d3fd9b9c 100755 --- a/test/validation.js +++ b/test/validation.js @@ -5,6 +5,7 @@ const Code = require('@hapi/code'); const Hapi = require('..'); const Inert = require('@hapi/inert'); const Joi = require('@hapi/joi'); +const JoiNext = require('@hapi/joi-next-test'); const Lab = require('@hapi/lab'); @@ -17,6 +18,30 @@ const expect = Code.expect; describe('validation', () => { + it('validates using joi v16', async () => { + + const server = Hapi.server(); + server.route({ + method: 'POST', + path: '/', + handler: () => 'ok', + options: { + validate: { + payload: JoiNext.object({ + a: JoiNext.number(), + b: JoiNext.array() + }) + } + } + }); + + const res1 = await server.inject({ url: '/', method: 'POST', payload: { a: '1', b: [1] } }); + expect(res1.statusCode).to.equal(200); + + const res2 = await server.inject({ url: '/', method: 'POST', payload: { a: 'x', b: [1] } }); + expect(res2.statusCode).to.equal(400); + }); + describe('inputs', () => { it('validates valid input', async () => {