keep response body out of logs during HEAD requests, by removing it from context
[websub-hub] / src / service.js
1 'use strict';
2
3 /**
4 * Here we extend the base API server to define our routes and any route-specific
5 * behavior (middlewares) before handing off to the manager.
6 */
7
8 const { Dingus } = require('@squeep/api-dingus');
9 const common = require('./common');
10 const Enum = require('./enum');
11 const Manager = require('./manager');
12 const { Authenticator, SessionManager } = require('@squeep/authentication-module');
13 const path = require('path');
14
15 const _fileScope = common.fileScope(__filename);
16
17 class Service extends Dingus {
18 constructor(logger, db, options) {
19 super(logger, {
20 ...options.dingus,
21 ignoreTrailingSlash: false,
22 });
23
24 this.manager = new Manager(logger, db, options);
25 this.authenticator = new Authenticator(logger, db, options);
26 this.sessionManager = new SessionManager(logger, this.authenticator, options);
27 this.staticPath = path.join(__dirname, '..', 'static');
28 this.loginPath = `${options.dingus.proxyPrefix}/admin/login`;
29
30 // Primary API endpoint
31 this.on('POST', '/', this.handlerPostRoot.bind(this));
32
33 // Information page about service
34 this.on(['GET', 'HEAD'], '/', this.handlerGetRoot.bind(this));
35
36 // Give load-balancers something to check
37 this.on(['GET', 'HEAD'], '/healthcheck', this.handlerGetHealthcheck.bind(this));
38
39 // Public information about topics
40 this.on('GET', '/info', this.handlerGetInfo.bind(this));
41 this.on('GET', '/info/', this.handlerGetInfo.bind(this));
42
43 // These routes are intended for accessing static content during development.
44 // In production, a proxy server would likely handle these first.
45 this.on(['GET', 'HEAD'], '/static', this.handlerRedirect.bind(this), `${options.dingus.proxyPrefix}/static/`);
46 this.on(['GET', 'HEAD'], '/static/', this.handlerGetStaticFile.bind(this), 'index.html');
47 this.on(['GET', 'HEAD'], '/static/:file', this.handlerGetStaticFile.bind(this));
48 this.on(['GET', 'HEAD'], '/favicon.ico', this.handlerGetStaticFile.bind(this), 'favicon.ico');
49 this.on(['GET', 'HEAD'], '/robots.txt', this.handlerGetStaticFile.bind(this), 'robots.txt');
50
51 // Private informational endpoints
52 this.on(['GET', 'HEAD'], '/admin', this.handlerRedirect.bind(this), `${options.dingus.proxyPrefix}/admin/`);
53 this.on(['GET', 'HEAD'], '/admin/', this.handlerGetAdminOverview.bind(this));
54 this.on(['GET', 'HEAD'], '/admin/topic/:topicId', this.handlerGetAdminTopicDetails.bind(this));
55
56 // Private data-editing endpoints
57 this.on(['PATCH', 'DELETE'], '/admin/topic/:topicId', this.handlerUpdateTopic.bind(this));
58 this.on(['PATCH', 'DELETE'], '/admin/subscription/:subscriptionId', this.handlerUpdateSubscription.bind(this));
59
60 // Private server-action endpoints
61 this.on('POST', '/admin/process', this.handlerPostAdminProcess.bind(this));
62
63 // Admin login
64 this.on(['GET', 'HEAD'], '/admin/login', this.handlerGetAdminLogin.bind(this));
65 this.on(['POST'], '/admin/login', this.handlerPostAdminLogin.bind(this));
66 this.on(['GET'], '/admin/logout', this.handlerGetAdminLogout.bind(this));
67 this.on(['GET'], '/admin/_ia', this.handlerGetAdminIA.bind(this));
68
69 }
70
71 /**
72 * Wrap the Dingus head handler, to remove the response body from the context,
73 * lest it be logged.
74 * @param {http.ClientRequest} req
75 * @param {http.ServerResponse} res
76 * @param {object} ctx
77 */
78 static setHeadHandler(req, res, ctx) {
79 if (req.method === 'HEAD') {
80 Dingus.setHeadHandler(req, res, ctx);
81 const origEnd = res.end.bind(res);
82 res.end = function (data, encoding, ...rest) {
83 const origResult = origEnd(data, encoding, ...rest);
84 delete ctx.responseBody;
85 return origResult;
86 };
87 }
88 }
89
90 /**
91 * @param {http.ClientRequest} req
92 * @param {http.ServerResponse} res
93 * @param {Object} ctx
94 */
95 async handlerPostRoot(req, res, ctx) {
96 const _scope = _fileScope('handlerPostRoot');
97 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
98
99 this.setResponseType(this.responseTypes, req, res, ctx);
100 await this.ingestBody(req, res, ctx);
101
102 await this.manager.postRoot(req, res, ctx);
103 }
104
105
106 /**
107 * @param {http.ClientRequest} req
108 * @param {http.ServerResponse} res
109 * @param {Object} ctx
110 */
111 async handlerGetRoot(req, res, ctx) {
112 const _scope = _fileScope('handlerGetRoot');
113 const responseTypes = [
114 Enum.ContentType.TextHTML,
115 ];
116 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
117
118 Service.setHeadHandler(req, res, ctx);
119
120 this.setResponseType(responseTypes, req, res, ctx);
121
122 await this.authenticator.sessionOptional(req, res, ctx, this.loginPath);
123
124 await this.manager.getRoot(req, res, ctx);
125 }
126
127
128 /**
129 * @param {http.ClientRequest} req
130 * @param {http.ServerResponse} res
131 * @param {Object} ctx
132 */
133 async handlerGetHealthcheck(req, res, ctx) {
134 const _scope = _fileScope('handlerGetHealthcheck');
135 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
136
137 Service.setHeadHandler(req, res, ctx);
138
139 this.setResponseType(this.responseTypes, req, res, ctx);
140
141 await this.manager.getHealthcheck(res, ctx);
142 }
143
144
145 /**
146 * @param {http.ClientRequest} req
147 * @param {http.ServerResponse} res
148 * @param {Object} ctx
149 */
150 async handlerGetInfo(req, res, ctx) {
151 const _scope = _fileScope('handlerGetInfo');
152 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
153
154 const responseTypes = [...this.responseTypes, Enum.ContentType.ImageSVG];
155
156 Service.setHeadHandler(req, res, ctx);
157
158 this.setResponseType(responseTypes, req, res, ctx);
159
160 await this.manager.getInfo(res, ctx);
161 }
162
163
164 /**
165 * @param {http.ClientRequest} req
166 * @param {http.ServerResponse} res
167 * @param {Object} ctx
168 */
169 async handlerGetAdminOverview(req, res, ctx) {
170 const _scope = _fileScope('handlerGetAdminOverview');
171 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
172
173 Service.setHeadHandler(req, res, ctx);
174
175 this.setResponseType(this.responseTypes, req, res, ctx);
176
177 await this.authenticator.sessionRequired(req, res, ctx, this.loginPath);
178
179 await this.manager.getAdminOverview(res, ctx);
180 }
181
182
183 /**
184 * @param {http.ClientRequest} req
185 * @param {http.ServerResponse} res
186 * @param {Object} ctx
187 */
188 async handlerGetAdminTopicDetails(req, res, ctx) {
189 const _scope = _fileScope('handlerGetAdminTopicDetails');
190 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
191
192 Service.setHeadHandler(req, res, ctx);
193
194 this.setResponseType(this.responseTypes, req, res, ctx);
195
196 await this.authenticator.sessionRequired(req, res, ctx, this.loginPath);
197
198 await this.manager.getTopicDetails(res, ctx);
199 }
200
201
202 /**
203 * Similar to super.ingestBody, but if no body was sent, do not parse (and
204 * thus avoid possible unsupported media type error).
205 * Also removes raw body from context, to simplify scrubbing sensitive data from logs.
206 * @param {http.ClientRequest} req
207 * @param {http.ServerResponse} res
208 * @param {Object} ctx
209 */
210 async maybeIngestBody(req, res, ctx) {
211 ctx.rawBody = await this.bodyData(req);
212 const contentType = Dingus.getRequestContentType(req);
213 if (ctx.rawBody) {
214 this.parseBody(contentType, ctx);
215 delete ctx.rawBody;
216 }
217 }
218
219
220 /**
221 * @param {http.ClientRequest} req
222 * @param {http.ServerResponse} res
223 * @param {Object} ctx
224 */
225 async handlerUpdateTopic(req, res, ctx) {
226 const _scope = _fileScope('handlerUpdateTopic');
227 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
228
229 this.setResponseType(this.responseTypes, req, res, ctx);
230
231 await this.authenticator.apiRequiredLocal(req, res, ctx);
232
233 await this.maybeIngestBody(req, res, ctx);
234 ctx.method = req.method;
235 await this.manager.updateTopic(res, ctx);
236 }
237
238
239 /**
240 * @param {http.ClientRequest} req
241 * @param {http.ServerResponse} res
242 * @param {Object} ctx
243 */
244 async handlerUpdateSubscription(req, res, ctx) {
245 const _scope = _fileScope('handlerUpdateSubscription');
246 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
247
248 this.setResponseType(this.responseTypes, req, res, ctx);
249
250 await this.authenticator.apiRequiredLocal(req, res, ctx);
251
252 await this.maybeIngestBody(req, res, ctx);
253 ctx.method = req.method;
254 await this.manager.updateSubscription(res, ctx);
255 }
256
257
258 /**
259 * @param {http.ClientRequest} req
260 * @param {http.ServerResponse} res
261 * @param {Object} ctx
262 */
263 async handlerPostAdminProcess(req, res, ctx) {
264 const _scope = _fileScope('handlerPostAdminProcess');
265 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
266
267 this.setResponseType(this.responseTypes, req, res, ctx);
268
269 await this.authenticator.apiRequiredLocal(req, res, ctx);
270
271 await this.manager.processTasks(res, ctx);
272 }
273
274
275 /**
276 * Delegate login to authentication module.
277 * @param {http.ClientRequest} req
278 * @param {http.ServerResponse} res
279 * @param {Object} ctx
280 */
281 async handlerGetAdminLogin(req, res, ctx) {
282 const _scope = _fileScope('handlerGetAdminLogin');
283 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
284
285 Service.setHeadHandler(req, res, ctx);
286
287 this.setResponseType(this.responseTypes, req, res, ctx);
288
289 await this.sessionManager.getAdminLogin(res, ctx);
290 }
291
292
293 /**
294 * Delegate login to authentication module.
295 * @param {http.ClientRequest} req
296 * @param {http.ServerResponse} res
297 * @param {Object} ctx
298 */
299 async handlerPostAdminLogin(req, res, ctx) {
300 const _scope = _fileScope('handlerPostAdminLogin');
301 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
302
303 this.setResponseType(this.responseTypes, req, res, ctx);
304
305 await this.authenticator.sessionOptionalLocal(req, res, ctx);
306
307 await this.maybeIngestBody(req, res, ctx);
308
309 await this.sessionManager.postAdminLogin(res, ctx);
310 }
311
312
313 /**
314 * Delegate login to authentication module.
315 * @param {http.ClientRequest} req
316 * @param {http.ServerResponse} res
317 * @param {Object} ctx
318 */
319 async handlerGetAdminLogout(req, res, ctx) {
320 const _scope = _fileScope('handlerGetAdminLogout');
321 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
322
323 this.setResponseType(this.responseTypes, req, res, ctx);
324
325 await this.authenticator.sessionOptionalLocal(req, res, ctx);
326
327 await this.sessionManager.getAdminLogout(res, ctx);
328 }
329
330
331 /**
332 * Delegate login to authentication module.
333 * @param {http.ClientRequest} req
334 * @param {http.ServerResponse} res
335 * @param {Object} ctx
336 */
337 async handlerGetAdminIA(req, res, ctx) {
338 const _scope = _fileScope('handlerGetAdminIA');
339 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
340
341 this.setResponseType(this.responseTypes, req, res, ctx);
342
343 // Special case here, to see cookie before session established
344 ctx.cookie = req.getHeader(Enum.Header.Cookie);
345
346 await this.sessionManager.getAdminIA(res, ctx);
347 }
348
349 }
350
351 module.exports = Service;