UNPKG

@twurple/eventsub-http

Version:

Listen to events on Twitch via their EventSub API using a HTTP/WebHook server.

270 lines (269 loc) 11.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.EventSubHttpBase = void 0; const tslib_1 = require("tslib"); const raw_body_1 = require("@d-fischer/raw-body"); const shared_utils_1 = require("@d-fischer/shared-utils"); const eventsub_base_1 = require("@twurple/eventsub-base"); const crypto = require("crypto"); /** * @private * @hideProtected * @inheritDoc */ class EventSubHttpBase extends eventsub_base_1.EventSubBase { constructor(config) { var _a, _b; // catch the examples copied verbatim if (!config.secret || config.secret === 'thisShouldBeARandomlyGeneratedFixedString') { throw new Error('Please generate a secret and pass it to the constructor!'); } if (config.secret.length < 10 || config.secret.length > 100) { throw new Error('Your secret must be between 10 and 100 characters long'); } super(config); this._readyToSubscribe = false; /** * Fires when a subscription is successfully verified or fails to verify. * * @eventListener * * @param success Whether the verification succeeded. * @param subscription The subscription that was verified. */ this.onVerify = this.registerEvent(); this._secret = config.secret; this._strictHostCheck = (_a = config.strictHostCheck) !== null && _a !== void 0 ? _a : true; this._helperRoutes = (_b = config.helperRoutes) !== null && _b !== void 0 ? _b : true; } /** @private */ async _getTransportOptionsForSubscription(subscription) { return { method: 'webhook', callback: await this._buildHookUrl(subscription.id), secret: this._secret, }; } /** @private */ async _getCliTestCommandForSubscription(subscription) { return `twitch event trigger ${subscription._cliName} -T webhook -F ${await this._buildHookUrl(subscription.id)} -s ${this._secret}`; } /** @private */ _isReadyToSubscribe() { return this._readyToSubscribe; } async _resumeExistingSubscriptions() { const subscriptions = await this._apiClient.eventSub.getSubscriptionsPaginated().getAll(); const urlPrefix = await this._buildHookUrl(''); this._twitchSubscriptions = new Map(subscriptions .map((sub) => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (sub._transport.method === 'webhook') { const url = sub._transport.callback; if (url.startsWith(urlPrefix)) { const id = url.slice(urlPrefix.length); if (!id.includes('/')) { return [id, sub]; } } } return undefined; }) .filter((x) => !!x)); for (const [subId, sub] of this._subscriptions) { sub.start(this._twitchSubscriptions.get(subId)); } } _createHandleRequest() { return async (req, res, next) => { if (await this._isHostDenied(req)) { res.setHeader('Content-Type', 'text/plain'); res.writeHead(404); res.end('Not OK'); return; } if (req.readableEnded) { next(new Error('The request body was already consumed by something else.\n' + "Please make sure you don't globally apply middlewares that consume the request body, " + 'such as express.json() or body-parser.')); return; } // The HTTP listener intentionally does not use the built-in resolution by Twitch subscription ID // to be able to recognize subscriptions from the URL (for avoiding unnecessary re-subscribing) const { id } = req.params; const subscription = this._subscriptions.get(id); const twitchSubscription = this._twitchSubscriptions.get(id); const type = req.headers['twitch-eventsub-message-type']; if (!subscription) { this._logger.warn(`Action ${type} of unknown event attempted: ${id}`); res.setHeader('Content-Type', 'text/plain'); res.writeHead(410); res.end('Not OK'); return; } try { const messageId = req.headers['twitch-eventsub-message-id']; const timestamp = req.headers['twitch-eventsub-message-timestamp']; const body = await (0, raw_body_1.default)(req, true); const algoAndSignature = req.headers['twitch-eventsub-message-signature']; if (algoAndSignature === undefined) { this._logger.warn(`Dropping unsigned message for action ${type} of event: ${id}`); res.setHeader('Content-Type', 'text/plain'); res.writeHead(410); res.end('Not OK'); return; } const verified = this._verifyData(messageId, timestamp, body, algoAndSignature); const data = JSON.parse(body); if (!verified) { this._logger.warn(`Could not verify action ${type} of event: ${id}`); if (type === 'webhook_callback_verification') { this.emit(this.onVerify, false, subscription); } res.setHeader('Content-Type', 'text/plain'); res.writeHead(410); res.end('Not OK'); return; } switch (type) { case 'webhook_callback_verification': { const verificationBody = data; this.emit(this.onVerify, true, subscription); subscription._verify(); if (twitchSubscription) { twitchSubscription._status = 'enabled'; } res.setHeader('Content-Length', verificationBody.challenge.length); res.setHeader('Content-Type', 'text/plain'); res.writeHead(200, undefined); res.end(verificationBody.challenge); this._logger.debug(`Successfully subscribed to event: ${id}`); break; } case 'notification': { // respond before handling payloads to make sure there is no timeout res.setHeader('Content-Type', 'text/plain'); res.writeHead(202); res.end('OK'); if (new Date(timestamp).getTime() < Date.now() - 10 * 60 * 1000) { this._logger.debug(`Old notification(s) prevented for event: ${id}`); break; } const payload = data; if ('events' in payload) { for (const event of payload.events) { this._handleSingleEventPayload(subscription, event.data, event.id); } } else { this._handleSingleEventPayload(subscription, payload.event, messageId); } break; } case 'revocation': { const revocationBody = data; this._dropSubscription(subscription.id); this._dropTwitchSubscription(subscription.id); this.emit(this.onRevoke, subscription, revocationBody.subscription.status); this._logger.debug(`Subscription revoked by Twitch for event: ${id}`); res.setHeader('Content-Type', 'text/plain'); res.writeHead(202); res.end('OK'); break; } default: { this._logger.warn(`Unknown action ${type} for event: ${id}`); res.setHeader('Content-Type', 'text/plain'); res.writeHead(400); res.end('Not OK'); break; } } } catch (e) { next(e); } }; } _createDropLegacyRequest() { return async (req, res, next) => { if (await this._isHostDenied(req)) { res.setHeader('Content-Type', 'text/plain'); res.writeHead(404); res.end('Not OK'); return; } const twitchSub = this._twitchSubscriptions.get(req.params.id); if (twitchSub) { await this._apiClient.eventSub.deleteSubscription(twitchSub.id); this._logger.debug(`Dropped legacy subscription for event: ${req.params.id}`); res.setHeader('Content-Type', 'text/plain'); res.writeHead(410); res.end('Not OK'); } else { next(); } }; } _createHandleHealthRequest() { return async (req, res) => { res.setHeader('Content-Type', 'text/plain'); if (await this._isHostDenied(req)) { res.writeHead(404); res.end('Not OK'); return; } res.writeHead(200); res.end('@twurple/eventsub-http is listening here'); }; } async _isHostDenied(req) { if (this._strictHostCheck) { const ip = req.socket.remoteAddress; if (ip === undefined) { // client disconnected already return true; } if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') { // localhost is always fine return false; } const { host } = req.headers; if (host === undefined) { this._logger.debug(`Denied request from ${ip} because its host header is empty`); return true; } const expectedHost = await this.getHostName(); if (host !== expectedHost) { this._logger.debug(`Denied request from ${ip} because its host header (${host}) doesn't match the expected value (${expectedHost})`); return true; } } return false; } _findTwitchSubscriptionToContinue(subscription) { return this._twitchSubscriptions.get(subscription.id); } /** @internal */ async _buildHookUrl(id) { var _a; const hostName = await this.getHostName(); // trim slashes on both ends const pathPrefix = (_a = (await this.getPathPrefix())) === null || _a === void 0 ? void 0 : _a.replace(/^\/|\/$/, ''); return `https://${hostName}${pathPrefix ? '/' : ''}${pathPrefix !== null && pathPrefix !== void 0 ? pathPrefix : ''}/event/${id}`; } /** @internal */ _verifyData(messageId, timestamp, body, algoAndSignature) { const [algorithm, signature] = algoAndSignature.split('=', 2); const hash = crypto .createHmac(algorithm, this._secret) .update(messageId + timestamp + body) .digest('hex'); return hash === signature; } } exports.EventSubHttpBase = EventSubHttpBase; tslib_1.__decorate([ (0, shared_utils_1.Enumerable)(false) ], EventSubHttpBase.prototype, "_secret", void 0);