@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
JavaScript
;
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);