display history of topic updates on topic details page
[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 this.on(['GET', 'HEAD'], '/admin/topic/:topicId/history.svg', this.handlerGetHistorySVG.bind(this));
56
57 // Private data-editing endpoints
58 this.on(['PATCH', 'DELETE'], '/admin/topic/:topicId', this.handlerUpdateTopic.bind(this));
59 this.on(['PATCH', 'DELETE'], '/admin/subscription/:subscriptionId', this.handlerUpdateSubscription.bind(this));
60
61 // Private server-action endpoints
62 this.on('POST', '/admin/process', this.handlerPostAdminProcess.bind(this));
63
64 // Admin login
65 this.on(['GET', 'HEAD'], '/admin/login', this.handlerGetAdminLogin.bind(this));
66 this.on(['POST'], '/admin/login', this.handlerPostAdminLogin.bind(this));
67 this.on(['GET'], '/admin/logout', this.handlerGetAdminLogout.bind(this));
68 this.on(['GET'], '/admin/_ia', this.handlerGetAdminIA.bind(this));
69
70 }
71
72 /**
73 * Wrap the Dingus head handler, to remove the response body from the context,
74 * lest it be logged.
75 * @param {http.ClientRequest} req
76 * @param {http.ServerResponse} res
77 * @param {object} ctx
78 */
79 static setHeadHandler(req, res, ctx) {
80 if (req.method === 'HEAD') {
81 Dingus.setHeadHandler(req, res, ctx);
82 const origEnd = res.end.bind(res);
83 res.end = function (data, encoding, ...rest) {
84 const origResult = origEnd(data, encoding, ...rest);
85 delete ctx.responseBody;
86 return origResult;
87 };
88 }
89 }
90
91 /**
92 * @param {http.ClientRequest} req
93 * @param {http.ServerResponse} res
94 * @param {Object} ctx
95 */
96 async handlerPostRoot(req, res, ctx) {
97 const _scope = _fileScope('handlerPostRoot');
98 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
99
100 this.setResponseType(this.responseTypes, req, res, ctx);
101 await this.ingestBody(req, res, ctx);
102
103 await this.manager.postRoot(req, res, ctx);
104 }
105
106
107 /**
108 * @param {http.ClientRequest} req
109 * @param {http.ServerResponse} res
110 * @param {Object} ctx
111 */
112 async handlerGetRoot(req, res, ctx) {
113 const _scope = _fileScope('handlerGetRoot');
114 const responseTypes = [
115 Enum.ContentType.TextHTML,
116 ];
117 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
118
119 Service.setHeadHandler(req, res, ctx);
120
121 this.setResponseType(responseTypes, req, res, ctx);
122
123 await this.authenticator.sessionOptional(req, res, ctx, this.loginPath);
124
125 await this.manager.getRoot(req, res, ctx);
126 }
127
128
129 /**
130 * @param {http.ClientRequest} req
131 * @param {http.ServerResponse} res
132 * @param {Object} ctx
133 */
134 async handlerGetHealthcheck(req, res, ctx) {
135 const _scope = _fileScope('handlerGetHealthcheck');
136 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
137
138 Service.setHeadHandler(req, res, ctx);
139
140 this.setResponseType(this.responseTypes, req, res, ctx);
141
142 await this.manager.getHealthcheck(res, ctx);
143 }
144
145
146 /**
147 * @param {http.ClientRequest} req
148 * @param {http.ServerResponse} res
149 * @param {Object} ctx
150 */
151 async handlerGetInfo(req, res, ctx) {
152 const _scope = _fileScope('handlerGetInfo');
153 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
154
155 const responseTypes = [...this.responseTypes, Enum.ContentType.ImageSVG];
156
157 Service.setHeadHandler(req, res, ctx);
158
159 this.setResponseType(responseTypes, req, res, ctx);
160
161 await this.manager.getInfo(res, ctx);
162 }
163
164
165 async handlerGetHistorySVG(req, res, ctx) {
166 const _scope = _fileScope('handlerGetHist');
167 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
168
169 const responseTypes = [Enum.ContentType.ImageSVG];
170
171 Service.setHeadHandler(req, res, ctx);
172
173 this.setResponseType(responseTypes, req, res, ctx);
174
175 await this.manager.getHistorySVG(res, ctx);
176 }
177
178
179 /**
180 * @param {http.ClientRequest} req
181 * @param {http.ServerResponse} res
182 * @param {Object} ctx
183 */
184 async handlerGetAdminOverview(req, res, ctx) {
185 const _scope = _fileScope('handlerGetAdminOverview');
186 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
187
188 Service.setHeadHandler(req, res, ctx);
189
190 this.setResponseType(this.responseTypes, req, res, ctx);
191
192 await this.authenticator.sessionRequired(req, res, ctx, this.loginPath);
193
194 await this.manager.getAdminOverview(res, ctx);
195 }
196
197
198 /**
199 * @param {http.ClientRequest} req
200 * @param {http.ServerResponse} res
201 * @param {Object} ctx
202 */
203 async handlerGetAdminTopicDetails(req, res, ctx) {
204 const _scope = _fileScope('handlerGetAdminTopicDetails');
205 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
206
207 Service.setHeadHandler(req, res, ctx);
208
209 this.setResponseType(this.responseTypes, req, res, ctx);
210
211 await this.authenticator.sessionRequired(req, res, ctx, this.loginPath);
212
213 await this.manager.getTopicDetails(res, ctx);
214 }
215
216
217 /**
218 * Similar to super.ingestBody, but if no body was sent, do not parse (and
219 * thus avoid possible unsupported media type error).
220 * Also removes raw body from context, to simplify scrubbing sensitive data from logs.
221 * @param {http.ClientRequest} req
222 * @param {http.ServerResponse} res
223 * @param {Object} ctx
224 */
225 async maybeIngestBody(req, res, ctx) {
226 ctx.rawBody = await this.bodyData(req);
227 const contentType = Dingus.getRequestContentType(req);
228 if (ctx.rawBody) {
229 this.parseBody(contentType, ctx);
230 delete ctx.rawBody;
231 }
232 }
233
234
235 /**
236 * @param {http.ClientRequest} req
237 * @param {http.ServerResponse} res
238 * @param {Object} ctx
239 */
240 async handlerUpdateTopic(req, res, ctx) {
241 const _scope = _fileScope('handlerUpdateTopic');
242 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
243
244 this.setResponseType(this.responseTypes, req, res, ctx);
245
246 await this.authenticator.apiRequiredLocal(req, res, ctx);
247
248 await this.maybeIngestBody(req, res, ctx);
249 ctx.method = req.method;
250 await this.manager.updateTopic(res, ctx);
251 }
252
253
254 /**
255 * @param {http.ClientRequest} req
256 * @param {http.ServerResponse} res
257 * @param {Object} ctx
258 */
259 async handlerUpdateSubscription(req, res, ctx) {
260 const _scope = _fileScope('handlerUpdateSubscription');
261 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
262
263 this.setResponseType(this.responseTypes, req, res, ctx);
264
265 await this.authenticator.apiRequiredLocal(req, res, ctx);
266
267 await this.maybeIngestBody(req, res, ctx);
268 ctx.method = req.method;
269 await this.manager.updateSubscription(res, ctx);
270 }
271
272
273 /**
274 * @param {http.ClientRequest} req
275 * @param {http.ServerResponse} res
276 * @param {Object} ctx
277 */
278 async handlerPostAdminProcess(req, res, ctx) {
279 const _scope = _fileScope('handlerPostAdminProcess');
280 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
281
282 this.setResponseType(this.responseTypes, req, res, ctx);
283
284 await this.authenticator.apiRequiredLocal(req, res, ctx);
285
286 await this.manager.processTasks(res, ctx);
287 }
288
289
290 /**
291 * Delegate login to authentication module.
292 * @param {http.ClientRequest} req
293 * @param {http.ServerResponse} res
294 * @param {Object} ctx
295 */
296 async handlerGetAdminLogin(req, res, ctx) {
297 const _scope = _fileScope('handlerGetAdminLogin');
298 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
299
300 Service.setHeadHandler(req, res, ctx);
301
302 this.setResponseType(this.responseTypes, req, res, ctx);
303
304 await this.sessionManager.getAdminLogin(res, ctx);
305 }
306
307
308 /**
309 * Delegate login to authentication module.
310 * @param {http.ClientRequest} req
311 * @param {http.ServerResponse} res
312 * @param {Object} ctx
313 */
314 async handlerPostAdminLogin(req, res, ctx) {
315 const _scope = _fileScope('handlerPostAdminLogin');
316 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
317
318 this.setResponseType(this.responseTypes, req, res, ctx);
319
320 await this.authenticator.sessionOptionalLocal(req, res, ctx);
321
322 await this.maybeIngestBody(req, res, ctx);
323
324 await this.sessionManager.postAdminLogin(res, ctx);
325 }
326
327
328 /**
329 * Delegate login to authentication module.
330 * @param {http.ClientRequest} req
331 * @param {http.ServerResponse} res
332 * @param {Object} ctx
333 */
334 async handlerGetAdminLogout(req, res, ctx) {
335 const _scope = _fileScope('handlerGetAdminLogout');
336 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
337
338 this.setResponseType(this.responseTypes, req, res, ctx);
339
340 await this.authenticator.sessionOptionalLocal(req, res, ctx);
341
342 await this.sessionManager.getAdminLogout(res, ctx);
343 }
344
345
346 /**
347 * Delegate login to authentication module.
348 * @param {http.ClientRequest} req
349 * @param {http.ServerResponse} res
350 * @param {Object} ctx
351 */
352 async handlerGetAdminIA(req, res, ctx) {
353 const _scope = _fileScope('handlerGetAdminIA');
354 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
355
356 this.setResponseType(this.responseTypes, req, res, ctx);
357
358 // Special case here, to see cookie before session established
359 ctx.cookie = req.getHeader(Enum.Header.Cookie);
360
361 await this.sessionManager.getAdminIA(res, ctx);
362 }
363
364 }
365
366 module.exports = Service;