490 lines
14 KiB
JavaScript
490 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
const Assert = require('assert');
|
|
|
|
const Bounce = require('@hapi/bounce');
|
|
const Catbox = require('@hapi/catbox');
|
|
const Hoek = require('@hapi/hoek');
|
|
const Subtext = require('@hapi/subtext');
|
|
const Validate = require('@hapi/validate');
|
|
|
|
const Auth = require('./auth');
|
|
const Config = require('./config');
|
|
const Cors = require('./cors');
|
|
const Ext = require('./ext');
|
|
const Handler = require('./handler');
|
|
const Headers = require('./headers');
|
|
const Security = require('./security');
|
|
const Streams = require('./streams');
|
|
const Validation = require('./validation');
|
|
|
|
|
|
const internals = {};
|
|
|
|
|
|
exports = module.exports = internals.Route = class {
|
|
|
|
constructor(route, server, options = {}) {
|
|
|
|
const core = server._core;
|
|
const realm = server.realm;
|
|
|
|
// Routing information
|
|
|
|
Config.apply('route', route, route.method, route.path);
|
|
|
|
const method = route.method.toLowerCase();
|
|
Hoek.assert(method !== 'head', 'Cannot set HEAD route:', route.path);
|
|
|
|
const path = realm.modifiers.route.prefix ? realm.modifiers.route.prefix + (route.path !== '/' ? route.path : '') : route.path;
|
|
Hoek.assert(path === '/' || path[path.length - 1] !== '/' || !core.settings.router.stripTrailingSlash, 'Path cannot end with a trailing slash when configured to strip:', route.method, route.path);
|
|
|
|
const vhost = realm.modifiers.route.vhost ?? route.vhost;
|
|
|
|
// Set identifying members (assert)
|
|
|
|
this.method = method;
|
|
this.path = path;
|
|
|
|
// Prepare configuration
|
|
|
|
let config = route.options ?? route.config ?? {};
|
|
if (typeof config === 'function') {
|
|
config = config.call(realm.settings.bind, server);
|
|
}
|
|
|
|
config = Config.enable(config); // Shallow clone
|
|
|
|
// Verify route level config (as opposed to the merged settings)
|
|
|
|
this._assert(method !== 'get' || !config.payload, 'Cannot set payload settings on HEAD or GET request');
|
|
this._assert(method !== 'get' || !config.validate?.payload, 'Cannot validate HEAD or GET request payload');
|
|
|
|
// Rules
|
|
|
|
this._assert(!route.rules || !config.rules, 'Route rules can only appear once'); // XOR
|
|
const rules = route.rules ?? config.rules;
|
|
const rulesConfig = internals.rules(rules, { method, path, vhost }, server);
|
|
delete config.rules;
|
|
|
|
// Handler
|
|
|
|
this._assert(route.handler || config.handler, 'Missing or undefined handler');
|
|
this._assert(!!route.handler ^ !!config.handler, 'Handler must only appear once'); // XOR
|
|
|
|
const handler = Config.apply('handler', route.handler ?? config.handler);
|
|
delete config.handler;
|
|
|
|
const handlerDefaults = Handler.defaults(method, handler, core);
|
|
|
|
// Apply settings in order: server <- handler <- realm <- route
|
|
|
|
const settings = internals.config([core.settings.routes, handlerDefaults, realm.settings, rulesConfig, config]);
|
|
this.settings = Config.apply('routeConfig', settings, method, path);
|
|
|
|
|
|
// Route members
|
|
|
|
this._core = core;
|
|
this.realm = realm;
|
|
|
|
this.settings.vhost = vhost;
|
|
this.settings.plugins = this.settings.plugins ?? {}; // Route-specific plugins settings, namespaced using plugin name
|
|
this.settings.app = this.settings.app ?? {}; // Route-specific application settings
|
|
|
|
// Path parsing
|
|
|
|
this._special = !!options.special;
|
|
this._analysis = this._core.router.analyze(this.path);
|
|
this.params = this._analysis.params;
|
|
this.fingerprint = this._analysis.fingerprint;
|
|
|
|
this.public = {
|
|
method: this.method,
|
|
path: this.path,
|
|
vhost,
|
|
realm,
|
|
settings: this.settings,
|
|
fingerprint: this.fingerprint,
|
|
auth: {
|
|
access: (request) => Auth.testAccess(request, this.public)
|
|
}
|
|
};
|
|
|
|
// Validation
|
|
|
|
this._setupValidation();
|
|
|
|
// Payload parsing
|
|
|
|
if (this.method === 'get') {
|
|
this.settings.payload = null;
|
|
}
|
|
else {
|
|
this.settings.payload.decoders = this._core.compression.decoders; // Reference the shared object to keep up to date
|
|
}
|
|
|
|
this._assert(!this.settings.validate.payload || this.settings.payload.parse, 'Route payload must be set to \'parse\' when payload validation enabled');
|
|
this._assert(!this.settings.validate.state || this.settings.state.parse, 'Route state must be set to \'parse\' when state validation enabled');
|
|
|
|
// Authentication configuration
|
|
|
|
this.settings.auth = this._special ? false : this._core.auth._setupRoute(this.settings.auth, path);
|
|
|
|
// Cache
|
|
|
|
if (this.method === 'get' &&
|
|
typeof this.settings.cache === 'object' &&
|
|
(this.settings.cache.expiresIn || this.settings.cache.expiresAt)) {
|
|
|
|
this.settings.cache._statuses = new Set(this.settings.cache.statuses);
|
|
this._cache = new Catbox.Policy({ expiresIn: this.settings.cache.expiresIn, expiresAt: this.settings.cache.expiresAt });
|
|
}
|
|
|
|
// CORS
|
|
|
|
this.settings.cors = Cors.route(this.settings.cors);
|
|
|
|
// Security
|
|
|
|
this.settings.security = Security.route(this.settings.security);
|
|
|
|
// Handler
|
|
|
|
this.settings.handler = Handler.configure(handler, this);
|
|
this._prerequisites = Handler.prerequisitesConfig(this.settings.pre);
|
|
|
|
// Route lifecycle
|
|
|
|
this._extensions = {
|
|
onPreResponse: Ext.combine(this, 'onPreResponse'),
|
|
onPostResponse: Ext.combine(this, 'onPostResponse')
|
|
};
|
|
|
|
if (this._special) {
|
|
this._cycle = [internals.drain, Handler.execute];
|
|
this.rebuild();
|
|
return;
|
|
}
|
|
|
|
this._extensions.onPreAuth = Ext.combine(this, 'onPreAuth');
|
|
this._extensions.onCredentials = Ext.combine(this, 'onCredentials');
|
|
this._extensions.onPostAuth = Ext.combine(this, 'onPostAuth');
|
|
this._extensions.onPreHandler = Ext.combine(this, 'onPreHandler');
|
|
this._extensions.onPostHandler = Ext.combine(this, 'onPostHandler');
|
|
|
|
this.rebuild();
|
|
}
|
|
|
|
_setupValidation() {
|
|
|
|
const validation = this.settings.validate;
|
|
if (this.method === 'get') {
|
|
validation.payload = null;
|
|
}
|
|
|
|
this._assert(!validation.params || this.params.length, 'Cannot set path parameters validations without path parameters');
|
|
|
|
for (const type of ['headers', 'params', 'query', 'payload', 'state']) {
|
|
validation[type] = Validation.compile(validation[type], this.settings.validate.validator, this.realm, this._core);
|
|
}
|
|
|
|
if (this.settings.response.schema !== undefined ||
|
|
this.settings.response.status) {
|
|
|
|
this.settings.response._validate = true;
|
|
|
|
const rule = this.settings.response.schema;
|
|
this.settings.response.status = this.settings.response.status ?? {};
|
|
const statuses = Object.keys(this.settings.response.status);
|
|
|
|
if (rule === true &&
|
|
!statuses.length) {
|
|
|
|
this.settings.response._validate = false;
|
|
}
|
|
else {
|
|
this.settings.response.schema = Validation.compile(rule, this.settings.validate.validator, this.realm, this._core);
|
|
for (const code of statuses) {
|
|
this.settings.response.status[code] = Validation.compile(this.settings.response.status[code], this.settings.validate.validator, this.realm, this._core);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
rebuild(event) {
|
|
|
|
if (event) {
|
|
this._extensions[event.type].add(event);
|
|
}
|
|
|
|
if (this._special) {
|
|
this._postCycle = this._extensions.onPreResponse.nodes ? [this._extensions.onPreResponse] : [];
|
|
this._buildMarshalCycle();
|
|
return;
|
|
}
|
|
|
|
// Build lifecycle array
|
|
|
|
this._cycle = [];
|
|
|
|
// 'onRequest'
|
|
|
|
if (this.settings.state.parse) {
|
|
this._cycle.push(internals.state);
|
|
}
|
|
|
|
if (this._extensions.onPreAuth.nodes) {
|
|
this._cycle.push(this._extensions.onPreAuth);
|
|
}
|
|
|
|
if (this._core.auth._enabled(this, 'authenticate')) {
|
|
this._cycle.push(Auth.authenticate);
|
|
}
|
|
|
|
if (this.method !== 'get') {
|
|
this._cycle.push(internals.payload);
|
|
|
|
if (this._core.auth._enabled(this, 'payload')) {
|
|
this._cycle.push(Auth.payload);
|
|
}
|
|
}
|
|
|
|
if (this._core.auth._enabled(this, 'authenticate') &&
|
|
this._extensions.onCredentials.nodes) {
|
|
|
|
this._cycle.push(this._extensions.onCredentials);
|
|
}
|
|
|
|
if (this._core.auth._enabled(this, 'access')) {
|
|
this._cycle.push(Auth.access);
|
|
}
|
|
|
|
if (this._extensions.onPostAuth.nodes) {
|
|
this._cycle.push(this._extensions.onPostAuth);
|
|
}
|
|
|
|
if (this.settings.validate.headers) {
|
|
this._cycle.push(Validation.headers);
|
|
}
|
|
|
|
if (this.settings.validate.params) {
|
|
this._cycle.push(Validation.params);
|
|
}
|
|
|
|
if (this.settings.validate.query) {
|
|
this._cycle.push(Validation.query);
|
|
}
|
|
|
|
if (this.settings.validate.payload) {
|
|
this._cycle.push(Validation.payload);
|
|
}
|
|
|
|
if (this.settings.validate.state) {
|
|
this._cycle.push(Validation.state);
|
|
}
|
|
|
|
if (this._extensions.onPreHandler.nodes) {
|
|
this._cycle.push(this._extensions.onPreHandler);
|
|
}
|
|
|
|
this._cycle.push(Handler.execute);
|
|
|
|
if (this._extensions.onPostHandler.nodes) {
|
|
this._cycle.push(this._extensions.onPostHandler);
|
|
}
|
|
|
|
this._postCycle = [];
|
|
|
|
if (this.settings.response._validate &&
|
|
this.settings.response.sample !== 0) {
|
|
|
|
this._postCycle.push(Validation.response);
|
|
}
|
|
|
|
if (this._extensions.onPreResponse.nodes) {
|
|
this._postCycle.push(this._extensions.onPreResponse);
|
|
}
|
|
|
|
this._buildMarshalCycle();
|
|
|
|
// onPostResponse
|
|
}
|
|
|
|
_buildMarshalCycle() {
|
|
|
|
this._marshalCycle = [Headers.type];
|
|
|
|
if (this.settings.cors) {
|
|
this._marshalCycle.push(Cors.headers);
|
|
}
|
|
|
|
if (this.settings.security) {
|
|
this._marshalCycle.push(Security.headers);
|
|
}
|
|
|
|
this._marshalCycle.push(Headers.entity);
|
|
|
|
if (this.method === 'get' ||
|
|
this.method === '*') {
|
|
|
|
this._marshalCycle.push(Headers.unmodified);
|
|
}
|
|
|
|
this._marshalCycle.push(Headers.cache);
|
|
this._marshalCycle.push(Headers.state);
|
|
this._marshalCycle.push(Headers.content);
|
|
|
|
if (this._core.auth._enabled(this, 'response')) {
|
|
this._marshalCycle.push(Auth.response); // Must be last in case requires access to headers
|
|
}
|
|
}
|
|
|
|
_assert(condition, message) {
|
|
|
|
if (condition) {
|
|
return;
|
|
}
|
|
|
|
if (this.method[0] !== '_') {
|
|
message = `${message}: ${this.method.toUpperCase()} ${this.path}`;
|
|
}
|
|
|
|
throw new Assert.AssertionError({
|
|
message,
|
|
actual: false,
|
|
expected: true,
|
|
operator: '==',
|
|
stackStartFunction: this._assert
|
|
});
|
|
}
|
|
};
|
|
|
|
|
|
internals.state = async function (request) {
|
|
|
|
request.state = {};
|
|
|
|
const req = request.raw.req;
|
|
const cookies = req.headers.cookie;
|
|
if (!cookies) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
var result = await request._core.states.parse(cookies);
|
|
}
|
|
catch (err) {
|
|
Bounce.rethrow(err, 'system');
|
|
var parseError = err;
|
|
}
|
|
|
|
const { states, failed = [] } = result ?? parseError;
|
|
request.state = states ?? {};
|
|
|
|
// Clear cookies
|
|
|
|
for (const item of failed) {
|
|
if (item.settings.clearInvalid) {
|
|
request._clearState(item.name);
|
|
}
|
|
}
|
|
|
|
if (!parseError) {
|
|
return;
|
|
}
|
|
|
|
parseError.header = cookies;
|
|
|
|
return request._core.toolkit.failAction(request, request.route.settings.state.failAction, parseError, { tags: ['state', 'error'] });
|
|
};
|
|
|
|
|
|
internals.payload = async function (request) {
|
|
|
|
if (request.method === 'get' ||
|
|
request.method === 'head') { // When route.method is '*'
|
|
|
|
return;
|
|
}
|
|
|
|
if (request.payload !== undefined) {
|
|
return internals.drain(request);
|
|
}
|
|
|
|
if (request._expectContinue) {
|
|
request._expectContinue = false;
|
|
request.raw.res.writeContinue();
|
|
}
|
|
|
|
try {
|
|
const { payload, mime } = await Subtext.parse(request.raw.req, request._tap(), request.route.settings.payload);
|
|
|
|
request._isPayloadPending = !!payload?._readableState;
|
|
request.mime = mime;
|
|
request.payload = payload;
|
|
}
|
|
catch (err) {
|
|
Bounce.rethrow(err, 'system');
|
|
|
|
await internals.drain(request);
|
|
|
|
request.mime = err.mime;
|
|
request.payload = null;
|
|
|
|
return request._core.toolkit.failAction(request, request.route.settings.payload.failAction, err, { tags: ['payload', 'error'] });
|
|
}
|
|
};
|
|
|
|
|
|
internals.drain = async function (request) {
|
|
|
|
// Flush out any pending request payload not consumed due to errors
|
|
|
|
if (request._expectContinue) {
|
|
request._isPayloadPending = false; // If we don't continue, client should not send a payload
|
|
request._expectContinue = false;
|
|
}
|
|
|
|
if (request._isPayloadPending) {
|
|
await Streams.drain(request.raw.req);
|
|
request._isPayloadPending = false;
|
|
}
|
|
};
|
|
|
|
|
|
internals.config = function (chain) {
|
|
|
|
if (!chain.length) {
|
|
return {};
|
|
}
|
|
|
|
let config = chain[0];
|
|
for (const item of chain) {
|
|
config = Hoek.applyToDefaults(config, item, { shallow: ['bind', 'validate.headers', 'validate.payload', 'validate.params', 'validate.query', 'validate.state'] });
|
|
}
|
|
|
|
return config;
|
|
};
|
|
|
|
|
|
internals.rules = function (rules, info, server) {
|
|
|
|
const configs = [];
|
|
|
|
let realm = server.realm;
|
|
while (realm) {
|
|
if (realm._rules) {
|
|
const source = !realm._rules.settings.validate ? rules : Validate.attempt(rules, realm._rules.settings.validate.schema, realm._rules.settings.validate.options);
|
|
const config = realm._rules.processor(source, info);
|
|
if (config) {
|
|
configs.unshift(config);
|
|
}
|
|
}
|
|
|
|
realm = realm.parent;
|
|
}
|
|
|
|
return internals.config(configs);
|
|
};
|