update dependencies, fixes to support new authentication features
[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 { initContext, navLinks } = require('./template/template-helper');
14 const path = require('path');
15
16 const _fileScope = common.fileScope(__filename);
17
18 class Service extends Dingus {
19 constructor(logger, db, options, asyncLocalStorage) {
20 super(logger, {
21 ...options.dingus,
22 ignoreTrailingSlash: false,
23 });
24 this.asyncLocalStorage = asyncLocalStorage;
25 this.manager = new Manager(logger, db, options);
26 this.authenticator = new Authenticator(logger, db, options);
27 this.sessionManager = new SessionManager(logger, this.authenticator, options);
28 this.staticPath = path.join(__dirname, '..', 'static');
29 this.loginPath = `${options.dingus.proxyPrefix}/admin/login`;
30
31 // Primary API endpoint
32 this.on('POST', '/', this.handlerPostRoot.bind(this));
33
34 // Information page about service
35 this.on(['GET'], '/', this.handlerGetRoot.bind(this));
36
37 // Give load-balancers something to check
38 this.on(['GET'], '/healthcheck', this.handlerGetHealthcheck.bind(this));
39
40 // Public information about topics
41 this.on('GET', '/info', this.handlerGetInfo.bind(this));
42 this.on('GET', '/info/', this.handlerGetInfo.bind(this));
43
44 // These routes are intended for accessing static content during development.
45 // In production, a proxy server would likely handle these first.
46 this.on(['GET'], '/static', this.handlerRedirect.bind(this), `${options.dingus.proxyPrefix}/static/`);
47 this.on(['GET'], '/static/', this.handlerGetStaticFile.bind(this), 'index.html');
48 this.on(['GET'], '/static/:file', this.handlerGetStaticFile.bind(this));
49 this.on(['GET'], '/favicon.ico', this.handlerGetStaticFile.bind(this), 'favicon.ico');
50 this.on(['GET'], '/robots.txt', this.handlerGetStaticFile.bind(this), 'robots.txt');
51
52 // Private informational endpoints
53 this.on(['GET'], '/admin', this.handlerRedirect.bind(this), `${options.dingus.proxyPrefix}/admin/`);
54 this.on(['GET'], '/admin/', this.handlerGetAdminOverview.bind(this));
55 this.on(['GET'], '/admin/topic/:topicId', this.handlerGetAdminTopicDetails.bind(this));
56 this.on(['GET'], '/admin/topic/:topicId/history.svg', this.handlerGetHistorySVG.bind(this));
57
58 // Private data-editing endpoints
59 this.on(['PATCH', 'DELETE'], '/admin/topic/:topicId', this.handlerUpdateTopic.bind(this));
60 this.on(['PATCH', 'DELETE'], '/admin/subscription/:subscriptionId', this.handlerUpdateSubscription.bind(this));
61
62 // Private server-action endpoints
63 this.on('POST', '/admin/process', this.handlerPostAdminProcess.bind(this));
64
65 // Admin login
66 this.on(['GET'], '/admin/login', this.handlerGetAdminLogin.bind(this));
67 this.on(['POST'], '/admin/login', this.handlerPostAdminLogin.bind(this));
68 this.on(['GET'], '/admin/logout', this.handlerGetAdminLogout.bind(this));
69 this.on(['GET'], '/admin/_ia', this.handlerGetAdminIA.bind(this));
70 this.on(['GET'], '/admin/settings', this.handlerGetAdminSettings.bind(this));
71 this.on(['POST'], '/admin/settings', this.handlerPostAdminSettings.bind(this));
72
73 }
74
75 /**
76 * @typedef {import('node:http')} http
77 */
78
79 /**
80 * Rearrange logging data.
81 * @param {http.ClientRequest} req request
82 * @param {http.ServerResponse} res response
83 * @param {object} ctx context
84 */
85 async preHandler(req, res, ctx) {
86 await super.preHandler(req, res, ctx);
87 ctx.url = req.url; // Persisted for logout redirect
88
89 const logObject = this.asyncLocalStorage.getStore();
90 // FIXME: for some reason, returning from the super.preHandler sometimes loses async context?
91 // Workaround until cause and solution are found.
92 if (logObject) {
93 logObject.requestId = ctx.requestId;
94 delete ctx.requestId;
95 } else {
96 this.logger.debug(_fileScope('preHandler'), 'lost async context', { req, ctx });
97 }
98 }
99
100
101 /**
102 * @param {http.ClientRequest} req request
103 * @param {http.ServerResponse} res response
104 * @param {object} ctx context
105 */
106 async handlerPostRoot(req, res, ctx) {
107 const _scope = _fileScope('handlerPostRoot');
108 this.logger.debug(_scope, 'called', { req, ctx });
109
110 initContext(ctx);
111
112 this.setResponseType(this.responseTypes, req, res, ctx);
113 await this.ingestBody(req, res, ctx);
114
115 await this.manager.postRoot(req, res, ctx);
116 }
117
118
119 /**
120 * @param {http.ClientRequest} req request
121 * @param {http.ServerResponse} res response
122 * @param {object} ctx context
123 */
124 async handlerGetRoot(req, res, ctx) {
125 const _scope = _fileScope('handlerGetRoot');
126 const responseTypes = [
127 Enum.ContentType.TextHTML,
128 ];
129 this.logger.debug(_scope, 'called', { req, ctx });
130
131 initContext(ctx);
132
133 this.setResponseType(responseTypes, req, res, ctx);
134
135 await this.authenticator.sessionOptional(req, res, ctx, this.loginPath);
136
137 await this.manager.getRoot(req, res, ctx);
138 }
139
140
141 /**
142 * @param {http.ClientRequest} req request
143 * @param {http.ServerResponse} res response
144 * @param {object} ctx context
145 */
146 async handlerGetHealthcheck(req, res, ctx) {
147 const _scope = _fileScope('handlerGetHealthcheck');
148 this.logger.debug(_scope, 'called', { req, ctx });
149
150 this.setResponseType(this.responseTypes, req, res, ctx);
151
152 await this.manager.getHealthcheck(res, ctx);
153 }
154
155
156 /**
157 * @param {http.ClientRequest} req request
158 * @param {http.ServerResponse} res response
159 * @param {object} ctx context
160 */
161 async handlerGetInfo(req, res, ctx) {
162 const _scope = _fileScope('handlerGetInfo');
163 this.logger.debug(_scope, 'called', { req, ctx });
164
165 const responseTypes = [...this.responseTypes, Enum.ContentType.ImageSVG];
166
167 this.setResponseType(responseTypes, req, res, ctx);
168
169 await this.manager.getInfo(res, ctx);
170 }
171
172
173 async handlerGetHistorySVG(req, res, ctx) {
174 const _scope = _fileScope('handlerGetHist');
175 this.logger.debug(_scope, 'called', { req, ctx });
176
177 const responseTypes = [Enum.ContentType.ImageSVG];
178
179 this.setResponseType(responseTypes, req, res, ctx);
180
181 await this.manager.getHistorySVG(res, ctx);
182 }
183
184
185 /**
186 * @param {http.ClientRequest} req request
187 * @param {http.ServerResponse} res response
188 * @param {object} ctx context
189 */
190 async handlerGetAdminOverview(req, res, ctx) {
191 const _scope = _fileScope('handlerGetAdminOverview');
192 this.logger.debug(_scope, 'called', { req, ctx });
193
194 initContext(ctx);
195
196 this.setResponseType(this.responseTypes, req, res, ctx);
197
198 if (await this.authenticator.sessionRequired(req, res, ctx, this.loginPath)) {
199 await this.manager.getAdminOverview(res, ctx);
200 }
201 }
202
203
204 /**
205 * @param {http.ClientRequest} req request
206 * @param {http.ServerResponse} res response
207 * @param {object} ctx context
208 */
209 async handlerGetAdminTopicDetails(req, res, ctx) {
210 const _scope = _fileScope('handlerGetAdminTopicDetails');
211 this.logger.debug(_scope, 'called', { req, ctx });
212
213 initContext(ctx);
214
215 this.setResponseType(this.responseTypes, req, res, ctx);
216
217 if (await this.authenticator.sessionRequired(req, res, ctx, this.loginPath)) {
218 await this.manager.getTopicDetails(res, ctx);
219 }
220 }
221
222
223 /**
224 * If no body was sent, do not parse (and thus avoid possible unsupported media type error).
225 * @param {http.ClientRequest} req request
226 * @param {http.ServerResponse} res response
227 * @param {object} ctx context
228 * @returns {Promise<object>} parsed body
229 */
230 async maybeIngestBody(req, res, ctx) {
231 return super.ingestBody(req, res, ctx, {
232 parseEmptyBody: false,
233 });
234 }
235
236
237 /**
238 * @param {http.ClientRequest} req request
239 * @param {http.ServerResponse} res response
240 * @param {object} ctx context
241 */
242 async handlerUpdateTopic(req, res, ctx) {
243 const _scope = _fileScope('handlerUpdateTopic');
244 this.logger.debug(_scope, 'called', { req, ctx });
245
246 this.setResponseType(this.responseTypes, req, res, ctx);
247
248 await this.authenticator.apiRequiredLocal(req, res, ctx);
249
250 await this.maybeIngestBody(req, res, ctx);
251 ctx.method = req.method;
252 await this.manager.updateTopic(res, ctx);
253 }
254
255
256 /**
257 * @param {http.ClientRequest} req request
258 * @param {http.ServerResponse} res response
259 * @param {object} ctx context
260 */
261 async handlerUpdateSubscription(req, res, ctx) {
262 const _scope = _fileScope('handlerUpdateSubscription');
263 this.logger.debug(_scope, 'called', { req, ctx });
264
265 this.setResponseType(this.responseTypes, req, res, ctx);
266
267 await this.authenticator.apiRequiredLocal(req, res, ctx);
268
269 await this.maybeIngestBody(req, res, ctx);
270 ctx.method = req.method;
271 await this.manager.updateSubscription(res, ctx);
272 }
273
274
275 /**
276 * @param {http.ClientRequest} req request
277 * @param {http.ServerResponse} res response
278 * @param {object} ctx context
279 */
280 async handlerPostAdminProcess(req, res, ctx) {
281 const _scope = _fileScope('handlerPostAdminProcess');
282 this.logger.debug(_scope, 'called', { req, ctx });
283
284 this.setResponseType(this.responseTypes, req, res, ctx);
285
286 await this.authenticator.apiRequiredLocal(req, res, ctx);
287
288 await this.manager.processTasks(res, ctx);
289 }
290
291
292 /**
293 * Delegate login to authentication module.
294 * @param {http.ClientRequest} req request
295 * @param {http.ServerResponse} res response
296 * @param {object} ctx context
297 */
298 async handlerGetAdminLogin(req, res, ctx) {
299 const _scope = _fileScope('handlerGetAdminLogin');
300 this.logger.debug(_scope, 'called', { req, ctx });
301
302 initContext(ctx);
303
304 this.setResponseType(this.responseTypes, req, res, ctx);
305
306 await this.sessionManager.getAdminLogin(res, ctx);
307 }
308
309
310 /**
311 * Delegate login to authentication module.
312 * @param {http.ClientRequest} req request
313 * @param {http.ServerResponse} res response
314 * @param {object} ctx context
315 */
316 async handlerPostAdminLogin(req, res, ctx) {
317 const _scope = _fileScope('handlerPostAdminLogin');
318 this.logger.debug(_scope, 'called', { req, ctx });
319
320 initContext(ctx);
321
322 this.setResponseType(this.responseTypes, req, res, ctx);
323
324 await this.authenticator.sessionOptionalLocal(req, res, ctx);
325
326 await this.maybeIngestBody(req, res, ctx);
327
328 await this.sessionManager.postAdminLogin(res, ctx);
329 }
330
331
332 /**
333 * Delegate account settings to authentication module.
334 * @param {http.ClientRequest} req request
335 * @param {http.ServerResponse} res response
336 * @param {object} ctx context
337 */
338 async handlerGetAdminSettings(req, res, ctx) {
339 const _scope = _fileScope('handlerGetAdminSettings');
340 this.logger.debug(_scope, 'called', { req, ctx });
341
342 initContext(ctx);
343
344 this.setResponseType(this.responseTypes, req, res, ctx);
345
346 if (await this.authenticator.sessionRequiredLocal(req, res, ctx)) {
347 await this.sessionManager.getAdminSettings(res, ctx, navLinks);
348 }
349 }
350
351
352 /**
353 * Delegate account settings to authentication module.
354 * @param {http.ClientRequest} req request
355 * @param {http.ServerResponse} res response
356 * @param {object} ctx context
357 */
358 async handlerPostAdminSettings(req, res, ctx) {
359 const _scope = _fileScope('handlerPostAdminSettings');
360 this.logger.debug(_scope, 'called', { req, ctx });
361
362 initContext(ctx);
363
364 this.setResponseType(this.responseTypes, req, res, ctx);
365
366 if (await this.authenticator.sessionRequiredLocal(req, res, ctx)) {
367 await this.maybeIngestBody(req, res, ctx);
368 await this.sessionManager.postAdminSettings(res, ctx, navLinks);
369 }
370 }
371
372
373 /**
374 * Delegate login to authentication module.
375 * @param {http.ClientRequest} req request
376 * @param {http.ServerResponse} res response
377 * @param {object} ctx context
378 */
379 async handlerGetAdminLogout(req, res, ctx) {
380 const _scope = _fileScope('handlerGetAdminLogout');
381 this.logger.debug(_scope, 'called', { req, ctx });
382
383 initContext(ctx);
384
385 this.setResponseType(this.responseTypes, req, res, ctx);
386
387 await this.authenticator.sessionOptionalLocal(req, res, ctx);
388
389 await this.sessionManager.getAdminLogout(res, ctx);
390 }
391
392
393 /**
394 * Delegate login to authentication module.
395 * @param {http.ClientRequest} req request
396 * @param {http.ServerResponse} res response
397 * @param {object} ctx context
398 */
399 async handlerGetAdminIA(req, res, ctx) {
400 const _scope = _fileScope('handlerGetAdminIA');
401 this.logger.debug(_scope, 'called', { req, ctx });
402
403 initContext(ctx);
404
405 this.setResponseType(this.responseTypes, req, res, ctx);
406
407 await this.sessionManager.getAdminIA(res, ctx);
408 }
409
410 }
411
412 module.exports = Service;