UNPKG

@slack/bolt

Version:

A framework for building Slack apps, fast.

423 lines 21.1 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); const node_http_1 = require("node:http"); const node_https_1 = require("node:https"); const node_url_1 = require("node:url"); const logger_1 = require("@slack/logger"); const oauth_1 = require("@slack/oauth"); const path_to_regexp_1 = require("path-to-regexp"); const errors_1 = require("../errors"); const httpFunc = __importStar(require("./HTTPModuleFunctions")); const HTTPResponseAck_1 = require("./HTTPResponseAck"); const custom_routes_1 = require("./custom-routes"); const verify_redirect_opts_1 = require("./verify-redirect-opts"); // Option keys for tls.createServer() and tls.createSecureContext(), exclusive of those for http.createServer() const httpsOptionKeys = [ 'ALPNProtocols', 'clientCertEngine', 'enableTrace', 'handshakeTimeout', 'rejectUnauthorized', 'requestCert', 'sessionTimeout', 'SNICallback', 'ticketKeys', 'pskCallback', 'pskIdentityHint', 'ca', 'cert', 'sigalgs', 'ciphers', 'clientCertEngine', 'crl', 'dhparam', 'ecdhCurve', 'honorCipherOrder', 'key', 'privateKeyEngine', 'privateKeyIdentifier', 'maxVersion', 'minVersion', 'passphrase', 'pfx', 'secureOptions', 'secureProtocol', 'sessionIdContext', ]; const missingServerErrorDescription = 'The receiver cannot be started because private state was mutated. Please report this to the maintainers.'; /** * Receives HTTP requests with Events, Slash Commands, and Actions */ class HTTPReceiver { endpoints; port; // you can override this value by the #start() method argument routes; signingSecret; processBeforeResponse; signatureVerification; app; requestListener; server; installer; installPath; // always defined when installer is defined installRedirectUriPath; // always defined when installer is defined installUrlOptions; // always defined when installer is defined installPathOptions; // always defined when installer is defined installCallbackOptions; // always defined when installer is defined stateVerification; // always defined when installer is defined logger; customPropertiesExtractor; dispatchErrorHandler; processEventErrorHandler; unhandledRequestHandler; unhandledRequestTimeoutMillis; constructor({ signingSecret = '', endpoints = ['/slack/events'], port = 3000, customRoutes = [], logger = undefined, logLevel = logger_1.LogLevel.INFO, processBeforeResponse = false, signatureVerification = true, clientId = undefined, clientSecret = undefined, stateSecret = undefined, redirectUri = undefined, installationStore = undefined, scopes = undefined, installerOptions = {}, customPropertiesExtractor = (_req) => ({}), dispatchErrorHandler = httpFunc.defaultDispatchErrorHandler, processEventErrorHandler = httpFunc.defaultProcessEventErrorHandler, unhandledRequestHandler = httpFunc.defaultUnhandledRequestHandler, unhandledRequestTimeoutMillis = 3001, }) { // Initialize instance variables, substituting defaults for each value this.signingSecret = signingSecret; this.processBeforeResponse = processBeforeResponse; this.signatureVerification = signatureVerification; this.logger = logger ?? (() => { const defaultLogger = new logger_1.ConsoleLogger(); defaultLogger.setLevel(logLevel); return defaultLogger; })(); this.endpoints = Array.isArray(endpoints) ? endpoints : [endpoints]; this.port = installerOptions?.port ? installerOptions.port : port; this.routes = (0, custom_routes_1.buildReceiverRoutes)(customRoutes); // Verify redirect options if supplied, throws coded error if invalid (0, verify_redirect_opts_1.verifyRedirectOpts)({ redirectUri, redirectUriPath: installerOptions.redirectUriPath }); this.stateVerification = installerOptions.stateVerification; // Initialize InstallProvider when it's required options are provided if (clientId !== undefined && clientSecret !== undefined && (this.stateVerification === false || // state store not needed stateSecret !== undefined || installerOptions.stateStore !== undefined) // user provided state store ) { this.installer = new oauth_1.InstallProvider({ clientId, clientSecret, stateSecret, installationStore, logger, logLevel, directInstall: installerOptions.directInstall, stateStore: installerOptions.stateStore, stateVerification: installerOptions.stateVerification, legacyStateVerification: installerOptions.legacyStateVerification, stateCookieName: installerOptions.stateCookieName, stateCookieExpirationSeconds: installerOptions.stateCookieExpirationSeconds, renderHtmlForInstallPath: installerOptions.renderHtmlForInstallPath, authVersion: installerOptions.authVersion, clientOptions: installerOptions.clientOptions, authorizationUrl: installerOptions.authorizationUrl, }); // Store the remaining instance variables that are related to using the InstallProvider this.installPath = installerOptions.installPath ?? '/slack/install'; this.installRedirectUriPath = installerOptions.redirectUriPath ?? '/slack/oauth_redirect'; this.installPathOptions = installerOptions.installPathOptions ?? {}; this.installCallbackOptions = installerOptions.callbackOptions ?? {}; this.installUrlOptions = { scopes: scopes ?? [], userScopes: installerOptions.userScopes, metadata: installerOptions.metadata, redirectUri, }; } this.customPropertiesExtractor = customPropertiesExtractor; this.dispatchErrorHandler = dispatchErrorHandler; this.processEventErrorHandler = processEventErrorHandler; this.unhandledRequestHandler = unhandledRequestHandler; this.unhandledRequestTimeoutMillis = unhandledRequestTimeoutMillis; // Assign the requestListener property by binding the unboundRequestListener to this instance this.requestListener = this.unboundRequestListener.bind(this); } init(app) { this.app = app; } start(portOrListenOptions, serverOptions = {}) { let createServerFn = node_http_1.createServer; // Decide which kind of server, HTTP or HTTPS, by searching for any keys in the serverOptions that are exclusive // to HTTPS if (Object.keys(serverOptions).filter((k) => httpsOptionKeys.includes(k)).length > 0) { createServerFn = node_https_1.createServer; } if (this.server !== undefined) { return Promise.reject(new errors_1.ReceiverInconsistentStateError('The receiver cannot be started because it was already started.')); } this.server = createServerFn(serverOptions, (req, res) => { try { this.requestListener(req, res); } catch (error) { // You may get an error here only when the requestListener failed // to start processing incoming requests, or your app receives a request to an unexpected path. this.dispatchErrorHandler({ error: error, logger: this.logger, request: req, response: res, }); } }); return new Promise((resolve, reject) => { if (this.server === undefined) { throw new errors_1.ReceiverInconsistentStateError(missingServerErrorDescription); } this.server.on('error', (error) => { if (this.server === undefined) { throw new errors_1.ReceiverInconsistentStateError(missingServerErrorDescription); } this.server.close(); // If the error event occurs before listening completes (like EADDRINUSE), this works well. However, if the // error event happens some after the Promise is already resolved, the error would be silently swallowed up. // The documentation doesn't describe any specific errors that can occur after listening has started, so this // feels safe. reject(error); }); this.server.on('close', () => { // Not removing all listeners because consumers could have added their own `close` event listener, and those // should be called. If the consumer doesn't dispose of any references to the server properly, this would be // a memory leak. // this.server?.removeAllListeners(); this.server = undefined; }); let listenOptions = this.port; if (portOrListenOptions !== undefined) { if (typeof portOrListenOptions === 'number') { listenOptions = portOrListenOptions; } else if (typeof portOrListenOptions === 'string') { listenOptions = Number(portOrListenOptions); } else if (typeof portOrListenOptions === 'object') { listenOptions = portOrListenOptions; } } this.server.listen(listenOptions, () => { if (this.server === undefined) { return reject(new errors_1.ReceiverInconsistentStateError(missingServerErrorDescription)); } return resolve(this.server); }); }); } // TODO: the arguments should be defined as the arguments to close() (which happen to be none), but for sake of // generic types stop() { if (this.server === undefined) { return Promise.reject(new errors_1.ReceiverInconsistentStateError('The receiver cannot be stopped because it was not started.')); } return new Promise((resolve, reject) => { this.server?.close((error) => { if (error !== undefined) { return reject(error); } this.server = undefined; return resolve(); }); }); } unboundRequestListener(req, res) { // Route the request // NOTE: the domain and scheme are irrelevant here. // The URL object is only used to safely obtain the path to match // TODO: we should add error handling for requests with falsy URLs / methods - could remove the cast here as a result. const { pathname: path } = new node_url_1.URL(req.url, 'http://localhost'); // biome-ignore lint/style/noNonNullAssertion: TODO: check for falsy method to remove the non null assertion const method = req.method.toUpperCase(); if (this.endpoints.includes(path) && method === 'POST') { // Handle incoming ReceiverEvent return this.handleIncomingEvent(req, res); } if (this.installer !== undefined && method === 'GET') { // TODO: it'd be better to check for falsiness and raise a readable error in any case, which lets us remove the non-null assertion // biome-ignore lint/style/noNonNullAssertion: When installer is defined then installPath and installRedirectUriPath are always defined const [installPath, installRedirectUriPath] = [this.installPath, this.installRedirectUriPath]; // Visiting the installation endpoint if (path === installPath) { // Render installation path (containing Add to Slack button) return this.handleInstallPathRequest(req, res); } // Installation has been initiated if (path === installRedirectUriPath) { // Handle OAuth callback request (to exchange authorization grant for a new access token) return this.handleInstallRedirectRequest(req, res); } } // Handle custom routes const routes = Object.keys(this.routes); for (let i = 0; i < routes.length; i += 1) { const route = routes[i]; const matchRegex = (0, path_to_regexp_1.match)(route, { decode: decodeURIComponent }); const pathMatch = matchRegex(path); if (pathMatch && this.routes[route][method] !== undefined) { const params = pathMatch.params; const message = Object.assign(req, { params }); return this.routes[route][method](message, res); } } // If the request did not match the previous conditions, an error is thrown. The error can be caught by // the caller in order to defer to other routing logic (similar to calling `next()` in connect middleware). // If you would like to customize the HTTP response for this pattern, // implement your own dispatchErrorHandler that handles an exception // with ErrorCode.HTTPReceiverDeferredRequestError. throw new errors_1.HTTPReceiverDeferredRequestError(`Unhandled HTTP request (${method}) made to ${path}`, req, res); } handleIncomingEvent(req, res) { // TODO:: this essentially ejects functionality out of the event loop, doesn't seem like a good idea unless intentional? should review // NOTE: Wrapped in an async closure for ease of using await (async () => { let bufferedReq; // biome-ignore lint/suspicious/noExplicitAny: http request bodies could be anything let body; // Verify authenticity try { bufferedReq = await httpFunc.parseAndVerifyHTTPRequest({ // If enabled: false, this method returns bufferedReq without verification enabled: this.signatureVerification, signingSecret: this.signingSecret, }, req); } catch (err) { const e = err; if (this.signatureVerification) { this.logger.warn(`Failed to parse and verify the request data: ${e.message}`); } else { this.logger.warn(`Failed to parse the request body: ${e.message}`); } httpFunc.buildNoBodyResponse(res, 401); return; } // Parse request body // The object containing the parsed body is not exposed to the caller. It is preferred to reduce mutations to the // req object, so that it's as reusable as possible. Later, we should consider adding an option for assigning the // parsed body to `req.body`, as this convention has been established by the popular `body-parser` package. try { body = httpFunc.parseHTTPRequestBody(bufferedReq); } catch (err) { const e = err; this.logger.warn(`Malformed request body: ${e.message}`); httpFunc.buildNoBodyResponse(res, 400); return; } // Handle SSL checks if (body.ssl_check) { httpFunc.buildNoBodyResponse(res, 200); return; } // Handle URL verification if (body.type === 'url_verification') { httpFunc.buildUrlVerificationResponse(res, body); return; } const ack = new HTTPResponseAck_1.HTTPResponseAck({ logger: this.logger, processBeforeResponse: this.processBeforeResponse, unhandledRequestHandler: this.unhandledRequestHandler, unhandledRequestTimeoutMillis: this.unhandledRequestTimeoutMillis, httpRequest: bufferedReq, httpResponse: res, }); // Structure the ReceiverEvent const event = { body, ack: ack.bind(), retryNum: httpFunc.extractRetryNumFromHTTPRequest(req), retryReason: httpFunc.extractRetryReasonFromHTTPRequest(req), customProperties: this.customPropertiesExtractor(bufferedReq), }; // Send the event to the app for processing try { await this.app?.processEvent(event); if (ack.storedResponse !== undefined) { // in the case of processBeforeResponse: true httpFunc.buildContentResponse(res, ack.storedResponse); this.logger.debug('stored response sent'); } } catch (error) { const acknowledgedByHandler = await this.processEventErrorHandler({ error: error, logger: this.logger, request: req, response: res, storedResponse: ack.storedResponse, }); if (acknowledgedByHandler) { // If the value is false, we don't touch the value as a race condition // with ack() call may occur especially when processBeforeResponse: false ack.ack(); } } })(); } handleInstallPathRequest(req, res) { // TODO:: this essentially ejects functionality out of the event loop, doesn't seem like a good idea unless intentional? should review // NOTE: Wrapped in an async closure for ease of using await (async () => { try { // biome-ignore lint/style/noNonNullAssertion: TODO: should check for falsiness await this.installer.handleInstallPath(req, res, this.installPathOptions, this.installUrlOptions); } catch (err) { const e = err; this.logger.error(`An unhandled error occurred while Bolt processed a request to the installation path (${e.message})`); this.logger.debug(`Error details: ${e}`); } })(); } handleInstallRedirectRequest(req, res) { // This function is only called from within unboundRequestListener after checking that installer is defined, and // when installer is defined then installCallbackOptions is always defined too. const [installer, installCallbackOptions, installUrlOptions] = [ // biome-ignore lint/style/noNonNullAssertion: TODO: should check for falsiness this.installer, // biome-ignore lint/style/noNonNullAssertion: TODO: should check for falsiness this.installCallbackOptions, // biome-ignore lint/style/noNonNullAssertion: TODO: should check for falsiness this.installUrlOptions, ]; const errorHandler = (err) => { this.logger.error('HTTPReceiver encountered an unexpected error while handling the OAuth install redirect. Please report to the maintainers.'); this.logger.debug(`Error details: ${err}`); }; if (this.stateVerification === false) { // when stateVerification is disabled pass install options directly to handler // since they won't be encoded in the state param of the generated url installer.handleCallback(req, res, installCallbackOptions, installUrlOptions).catch(errorHandler); } else { installer.handleCallback(req, res, installCallbackOptions).catch(errorHandler); } } } exports.default = HTTPReceiver; //# sourceMappingURL=HTTPReceiver.js.map