update dependencies and devDependencies, fix lint issues
[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, asyncLocalStorage) {
19 super(logger, {
20 ...options.dingus,
21 ignoreTrailingSlash: false,
22 });
23 this.asyncLocalStorage = asyncLocalStorage;
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 * @typedef {import('node:http')} http
74 */
75
76 /**
77 * Rearrange logging data.
78 * @param {http.ClientRequest} req request
79 * @param {http.ServerResponse} res response
80 * @param {object} ctx context
81 */
82 async preHandler(req, res, ctx) {
83 await super.preHandler(req, res, ctx);
84 const logObject = this.asyncLocalStorage.getStore();
85 // FIXME: for some reason, returning from the super.preHandler sometimes loses async context?
86 // Workaround until cause and solution are found.
87 if (logObject) {
88 logObject.requestId = ctx.requestId;
89 delete ctx.requestId;
90 } else {
91 this.logger.debug(_fileScope('preHandler'), 'lost async context', { req, ctx });
92 }
93 }
94
95
96 /**
97 * @param {http.ClientRequest} req request
98 * @param {http.ServerResponse} res response
99 * @param {object} ctx context
100 */
101 async handlerPostRoot(req, res, ctx) {
102 const _scope = _fileScope('handlerPostRoot');
103 this.logger.debug(_scope, 'called', { req, ctx });
104
105 this.setResponseType(this.responseTypes, req, res, ctx);
106 await this.ingestBody(req, res, ctx);
107
108 await this.manager.postRoot(req, res, ctx);
109 }
110
111
112 /**
113 * @param {http.ClientRequest} req request
114 * @param {http.ServerResponse} res response
115 * @param {object} ctx context
116 */
117 async handlerGetRoot(req, res, ctx) {
118 const _scope = _fileScope('handlerGetRoot');
119 const responseTypes = [
120 Enum.ContentType.TextHTML,
121 ];
122 this.logger.debug(_scope, 'called', { req, ctx });
123
124 Dingus.setHeadHandler(req, res, ctx);
125
126 this.setResponseType(responseTypes, req, res, ctx);
127
128 await this.authenticator.sessionOptional(req, res, ctx, this.loginPath);
129
130 await this.manager.getRoot(req, res, ctx);
131 }
132
133
134 /**
135 * @param {http.ClientRequest} req request
136 * @param {http.ServerResponse} res response
137 * @param {object} ctx context
138 */
139 async handlerGetHealthcheck(req, res, ctx) {
140 const _scope = _fileScope('handlerGetHealthcheck');
141 this.logger.debug(_scope, 'called', { req, ctx });
142
143 Dingus.setHeadHandler(req, res, ctx);
144
145 this.setResponseType(this.responseTypes, req, res, ctx);
146
147 await this.manager.getHealthcheck(res, ctx);
148 }
149
150
151 /**
152 * @param {http.ClientRequest} req request
153 * @param {http.ServerResponse} res response
154 * @param {object} ctx context
155 */
156 async handlerGetInfo(req, res, ctx) {
157 const _scope = _fileScope('handlerGetInfo');
158 this.logger.debug(_scope, 'called', { req, ctx });
159
160 const responseTypes = [...this.responseTypes, Enum.ContentType.ImageSVG];
161
162 Dingus.setHeadHandler(req, res, ctx);
163
164 this.setResponseType(responseTypes, req, res, ctx);
165
166 await this.manager.getInfo(res, ctx);
167 }
168
169
170 async handlerGetHistorySVG(req, res, ctx) {
171 const _scope = _fileScope('handlerGetHist');
172 this.logger.debug(_scope, 'called', { req, ctx });
173
174 const responseTypes = [Enum.ContentType.ImageSVG];
175
176 Dingus.setHeadHandler(req, res, ctx);
177
178 this.setResponseType(responseTypes, req, res, ctx);
179
180 await this.manager.getHistorySVG(res, ctx);
181 }
182
183
184 /**
185 * @param {http.ClientRequest} req request
186 * @param {http.ServerResponse} res response
187 * @param {object} ctx context
188 */
189 async handlerGetAdminOverview(req, res, ctx) {
190 const _scope = _fileScope('handlerGetAdminOverview');
191 this.logger.debug(_scope, 'called', { req, ctx });
192
193 Dingus.setHeadHandler(req, res, ctx);
194
195 this.setResponseType(this.responseTypes, req, res, ctx);
196
197 if (await this.authenticator.sessionRequired(req, res, ctx, this.loginPath)) {
198 await this.manager.getAdminOverview(res, ctx);
199 }
200 }
201
202
203 /**
204 * @param {http.ClientRequest} req request
205 * @param {http.ServerResponse} res response
206 * @param {object} ctx context
207 */
208 async handlerGetAdminTopicDetails(req, res, ctx) {
209 const _scope = _fileScope('handlerGetAdminTopicDetails');
210 this.logger.debug(_scope, 'called', { req, ctx });
211
212 Dingus.setHeadHandler(req, res, ctx);
213
214 this.setResponseType(this.responseTypes, req, res, ctx);
215
216 if (await this.authenticator.sessionRequired(req, res, ctx, this.loginPath)) {
217 await this.manager.getTopicDetails(res, ctx);
218 }
219 }
220
221
222 /**
223 * If no body was sent, do not parse (and thus avoid possible unsupported media type error).
224 * @param {http.ClientRequest} req request
225 * @param {http.ServerResponse} res response
226 * @param {object} ctx context
227 * @returns {Promise<object>} parsed body
228 */
229 async maybeIngestBody(req, res, ctx) {
230 return super.ingestBody(req, res, ctx, {
231 parseEmptyBody: false,
232 });
233 }
234
235
236 /**
237 * @param {http.ClientRequest} req request
238 * @param {http.ServerResponse} res response
239 * @param {object} ctx context
240 */
241 async handlerUpdateTopic(req, res, ctx) {
242 const _scope = _fileScope('handlerUpdateTopic');
243 this.logger.debug(_scope, 'called', { req, ctx });
244
245 this.setResponseType(this.responseTypes, req, res, ctx);
246
247 await this.authenticator.apiRequiredLocal(req, res, ctx);
248
249 await this.maybeIngestBody(req, res, ctx);
250 ctx.method = req.method;
251 await this.manager.updateTopic(res, ctx);
252 }
253
254
255 /**
256 * @param {http.ClientRequest} req request
257 * @param {http.ServerResponse} res response
258 * @param {object} ctx context
259 */
260 async handlerUpdateSubscription(req, res, ctx) {
261 const _scope = _fileScope('handlerUpdateSubscription');
262 this.logger.debug(_scope, 'called', { req, ctx });
263
264 this.setResponseType(this.responseTypes, req, res, ctx);
265
266 await this.authenticator.apiRequiredLocal(req, res, ctx);
267
268 await this.maybeIngestBody(req, res, ctx);
269 ctx.method = req.method;
270 await this.manager.updateSubscription(res, ctx);
271 }
272
273
274 /**
275 * @param {http.ClientRequest} req request
276 * @param {http.ServerResponse} res response
277 * @param {object} ctx context
278 */
279 async handlerPostAdminProcess(req, res, ctx) {
280 const _scope = _fileScope('handlerPostAdminProcess');
281 this.logger.debug(_scope, 'called', { req, ctx });
282
283 this.setResponseType(this.responseTypes, req, res, ctx);
284
285 await this.authenticator.apiRequiredLocal(req, res, ctx);
286
287 await this.manager.processTasks(res, ctx);
288 }
289
290
291 /**
292 * Delegate login to authentication module.
293 * @param {http.ClientRequest} req request
294 * @param {http.ServerResponse} res response
295 * @param {object} ctx context
296 */
297 async handlerGetAdminLogin(req, res, ctx) {
298 const _scope = _fileScope('handlerGetAdminLogin');
299 this.logger.debug(_scope, 'called', { req, ctx });
300
301 Dingus.setHeadHandler(req, res, ctx);
302
303 this.setResponseType(this.responseTypes, req, res, ctx);
304
305 await this.sessionManager.getAdminLogin(res, ctx);
306 }
307
308
309 /**
310 * Delegate login to authentication module.
311 * @param {http.ClientRequest} req request
312 * @param {http.ServerResponse} res response
313 * @param {object} ctx context
314 */
315 async handlerPostAdminLogin(req, res, ctx) {
316 const _scope = _fileScope('handlerPostAdminLogin');
317 this.logger.debug(_scope, 'called', { req, ctx });
318
319 this.setResponseType(this.responseTypes, req, res, ctx);
320
321 await this.authenticator.sessionOptionalLocal(req, res, ctx);
322
323 await this.maybeIngestBody(req, res, ctx);
324
325 await this.sessionManager.postAdminLogin(res, ctx);
326 }
327
328
329 /**
330 * Delegate login to authentication module.
331 * @param {http.ClientRequest} req request
332 * @param {http.ServerResponse} res response
333 * @param {object} ctx context
334 */
335 async handlerGetAdminLogout(req, res, ctx) {
336 const _scope = _fileScope('handlerGetAdminLogout');
337 this.logger.debug(_scope, 'called', { req, ctx });
338
339 this.setResponseType(this.responseTypes, req, res, ctx);
340
341 await this.authenticator.sessionOptionalLocal(req, res, ctx);
342
343 await this.sessionManager.getAdminLogout(res, ctx);
344 }
345
346
347 /**
348 * Delegate login to authentication module.
349 * @param {http.ClientRequest} req request
350 * @param {http.ServerResponse} res response
351 * @param {object} ctx context
352 */
353 async handlerGetAdminIA(req, res, ctx) {
354 const _scope = _fileScope('handlerGetAdminIA');
355 this.logger.debug(_scope, 'called', { req, ctx });
356
357 this.setResponseType(this.responseTypes, req, res, ctx);
358
359 // Special case here, to see cookie before session established
360 ctx.cookie = req.getHeader(Enum.Header.Cookie);
361
362 await this.sessionManager.getAdminIA(res, ctx);
363 }
364
365 }
366
367 module.exports = Service;