UNPKG

7.7 kBJavaScriptView Raw
1/*
2 * Copyright 2018 Adobe. All rights reserved.
3 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License. You may obtain a copy
5 * of the License at http://www.apache.org/licenses/LICENSE-2.0
6 *
7 * Unless required by applicable law or agreed to in writing, software distributed under
8 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9 * OF ANY KIND, either express or implied. See the License for the specific language
10 * governing permissions and limitations under the License.
11 */
12
13/* eslint-disable no-underscore-dangle */
14
15const crypto = require('crypto');
16const path = require('path');
17const fse = require('fs-extra');
18const { Probot, Server } = require('probot');
19const pino = require('pino');
20const { expressify, wrap } = require('@adobe/openwhisk-action-utils');
21const { logger } = require('@adobe/openwhisk-action-logger');
22const { getPrivateKey } = require('@probot/get-private-key');
23const { resolveAppFunction } = require('probot/lib/helpers/resolve-app-function');
24const hbs = require('hbs');
25const PinoInterface = require('./pino-interface.js');
26
27const ERROR = {
28 statusCode: 500,
29 headers: {
30 'Cache-Control': 'no-store, private, must-revalidate',
31 },
32 body: 'Internal Server Error.',
33};
34
35/**
36 * Validate if the payload is valid.
37 * @param secret the webhook secret
38 * @param payload the payload body
39 * @param signature the signature of the POST
40 * @throws Error if the payload is not valid.
41 */
42function validatePayload(secret, payload = '', signature) {
43 if (!signature) {
44 throw Error('signature required');
45 }
46 if (!secret) {
47 throw Error('secret required');
48 }
49 const sig = signature.split('=');
50 if (sig.length !== 2) {
51 throw Error('invalid signature format.');
52 }
53 const signed = crypto.createHmac(sig[0], secret).update(payload, 'utf-8').digest();
54 if (!crypto.timingSafeEqual(signed, Buffer.from(sig[1], 'hex'))) {
55 throw Error('signature not valid.');
56 }
57}
58
59module.exports = class OpenWhiskWrapper {
60 constructor() {
61 this._viewsDirectory = [];
62 this._apps = [];
63 this._appId = null;
64 this._secret = null;
65 this._privateKey = null;
66 this._githubToken = null;
67 this._webhookPath = '/';
68 }
69
70 async resolveApps() {
71 return Promise.all(this._apps.map(async (app, idx, apps) => {
72 if (typeof app === 'string') {
73 // eslint-disable-next-line no-param-reassign
74 apps[idx] = await resolveAppFunction(app);
75 }
76 }));
77 }
78
79 withApp(app) {
80 this._apps.push(app);
81 return this;
82 }
83
84 withViewsDirectory(value) {
85 this._viewsDirectory.push(path.resolve(process.cwd(), value));
86 return this;
87 }
88
89 withAppId(appId) {
90 this._appId = appId;
91 return this;
92 }
93
94 withWebhookSecret(secret) {
95 this._secret = secret;
96 return this;
97 }
98
99 withGithubPrivateKey(key) {
100 this._privateKey = key;
101 return this;
102 }
103
104 withGithubToken(token) {
105 this._githubToken = token;
106 return this;
107 }
108
109 withWebHookPath(value) {
110 this._webhookPath = value;
111 return this;
112 }
113
114 /**
115 * Creates a probot server that is suitable for local development.
116 * @param params - the 'action' params.
117 * @returns {Probot} the probot (server).
118 */
119 async createProbotServer(params) {
120 // add the fields that are usually set during run()
121 this._appId = params.GH_APP_ID;
122 this._secret = params.GH_APP_WEBHOOK_SECRET;
123 this._privateKey = params.GH_APP_PRIVATE_KEY;
124 await logger.init(params);
125 return this.initProbot(params);
126 }
127
128 async initProbot(params) {
129 const log = pino({
130 level: process.env.LOG_LEVEL || 'info',
131 name: 'probot',
132 serializers: {
133 err: pino.stdSerializers.err,
134 req: pino.stdSerializers.req,
135 res: PinoInterface.resSerializer,
136 },
137 }, new PinoInterface());
138
139 const options = {
140 id: this._appId,
141 secret: this._secret,
142 privateKey: this._privateKey,
143 catchErrors: false,
144 githubToken: this._githubToken,
145 log,
146 };
147
148 const server = new Server({
149 log: options.log,
150 webhookPath: this._webhookPath,
151 // webhookProxy,
152 Probot: Probot.defaults(options),
153 });
154
155 if (this._viewsDirectory.length === 0) {
156 this.withViewsDirectory('./views');
157 }
158
159 server.expressApp.set('views', this._viewsDirectory);
160 log.debug('Set view directory to %s', server.expressApp.get('views'));
161 const hbsEngine = hbs.create();
162 hbsEngine.localsAsTemplateData(server.expressApp);
163 server.expressApp.engine('hbs', hbsEngine.__express);
164 // load pkgJson as express local
165 try {
166 server.expressApp.locals.pkgJson = await fse.readJson(path.join(process.cwd(), 'package.json'));
167 } catch (e) {
168 log.info('unable to load package.json %s', e);
169 }
170
171 await server.load((app, opts) => {
172 this._apps.forEach((handler) => {
173 handler({ app, getRouter: opts.getRouter }, params, options);
174 });
175 });
176
177 return server;
178 }
179
180 create() {
181 const run = async (params) => {
182 const {
183 __ow_method: method,
184 __ow_headers: headers,
185 __ow_body: body,
186 __ow_logger: log,
187 } = params;
188
189 // set APP_ID, WEBHOOK_SECRET and PRIVATE_KEY if defined via params
190 if (!this._appId) {
191 this._appId = params.GH_APP_ID;
192 }
193 if (!this._secret) {
194 this._secret = params.GH_APP_WEBHOOK_SECRET;
195 }
196 if (!this._privateKey) {
197 this._privateKey = params.GH_APP_PRIVATE_KEY || getPrivateKey();
198 }
199
200 await this.resolveApps();
201
202 // check if the event is triggered via params.
203 let { event, eventId, payload } = params;
204 const { signature } = params;
205
206 let delegateRequest = true;
207 if (event && eventId && signature && payload) {
208 // validate webhook
209 try {
210 validatePayload(this._secret, payload, signature);
211 payload = JSON.parse(payload);
212 } catch (e) {
213 log.error(`Error validating payload: ${e.message}`);
214 return ERROR;
215 }
216 log.debug('payload signature valid.');
217 delegateRequest = false;
218 } else if (method === 'post' && headers) {
219 // eslint-disable-next-line no-param-reassign
220 if (headers['content-type'] === 'application/json') {
221 payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8'));
222 }
223 event = headers['x-github-event'];
224 eventId = headers['x-github-delivery'];
225 }
226
227 if (eventId && payload && payload.action) {
228 log.info(`Received event ${eventId} ${event}${payload.action ? (`.${payload.action}`) : ''}`);
229 }
230
231 let probotServer;
232 try {
233 log.debug('intializing probot...');
234 probotServer = await this.initProbot(params);
235 } catch (e) {
236 log.error(`Error while loading probot: ${e.stack || e}`);
237 return ERROR;
238 }
239
240 try {
241 let result = {
242 statusCode: 200,
243 headers: {},
244 body: 'ok\n',
245 };
246
247 if (delegateRequest) {
248 result = await expressify(probotServer.expressApp)(params);
249 } else {
250 // let probot handle the event
251 await probotServer.probotApp.receive({
252 name: event,
253 payload,
254 });
255 }
256
257 // set cache control header if not set
258 if (!result.headers['cache-control']) {
259 result.headers['cache-control'] = 'no-store, private, must-revalidate';
260 }
261
262 return result;
263 } catch (err) {
264 log.error(err);
265 return ERROR;
266 }
267 };
268
269 return wrap(run).with(logger);
270 }
271};