Merge branch 'v1.1-dev' as v1.1.1
[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 = require('./authenticator');
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.staticPath = path.join(__dirname, '..', 'static');
27
28 // Primary API endpoint
29 this.on('POST', '/', this.handlerPostRoot.bind(this));
30
31 // Information page about service
32 this.on(['GET', 'HEAD'], '/', this.handlerGetRoot.bind(this));
33
34 // Give load-balancers something to check
35 this.on(['GET', 'HEAD'], '/healthcheck', this.handlerGetHealthcheck.bind(this));
36
37 // Public information about topics
38 this.on('GET', '/info', this.handlerGetInfo.bind(this));
39 this.on('GET', '/info/', this.handlerGetInfo.bind(this));
40
41 // These routes are intended for accessing static content during development.
42 // In production, a proxy server would likely handle these first.
43 this.on(['GET', 'HEAD'], '/static', (req, res, ctx) => this.handlerRedirect(req, res, ctx, `${options.dingus.proxyPrefix}/static/`));
44 this.on(['GET', 'HEAD'], '/static/', (req, res, ctx) => this.handlerGetStaticFile(req, res, ctx, 'index.html'));
45 this.on(['GET', 'HEAD'], '/static/:file', this.handlerGetStaticFile.bind(this));
46 this.on(['GET', 'HEAD'], '/favicon.ico', (req, res, ctx) => this.handlerGetStaticFile(req, res, ctx, 'favicon.ico'));
47 this.on(['GET', 'HEAD'], '/robots.txt', (req, res, ctx) => this.handlerGetStaticFile(req, res, ctx, 'robots.txt'));
48
49 // Private informational endpoints
50 this.on(['GET', 'HEAD'], '/admin', (req, res, ctx) => this.handlerRedirect(req, res, ctx, `${options.dingus.proxyPrefix}/admin/`));
51 this.on(['GET', 'HEAD'], '/admin/', this.handlerGetAdminOverview.bind(this));
52 this.on(['GET', 'HEAD'], '/admin/topic/:topicId', this.handlerGetAdminTopicDetails.bind(this));
53
54 // Private data-editing endpoints
55 this.on(['PATCH', 'DELETE'], '/admin/topic/:topicId', this.handlerUpdateTopic.bind(this));
56 this.on(['PATCH', 'DELETE'], '/admin/subscription/:subscriptionId', this.handlerUpdateSubscription.bind(this));
57
58 // Private server-action endpoints
59 this.on('POST', '/admin/process', this.handlerPostAdminProcess.bind(this));
60 }
61
62
63 /**
64 * @param {http.ClientRequest} req
65 * @param {http.ServerResponse} res
66 * @param {Object} ctx
67 * @param {String} newPath
68 */
69 async handlerRedirect(req, res, ctx, newPath) {
70 const _scope = _fileScope('handlerRedirect');
71 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
72
73 res.setHeader(Enum.Header.Location, newPath);
74 res.statusCode = 307; // Temporary Redirect
75 res.end();
76 }
77
78
79 /**
80 * @param {http.ClientRequest} req
81 * @param {http.ServerResponse} res
82 * @param {object} ctx
83 */
84 async handlerPostRoot(req, res, ctx) {
85 const _scope = _fileScope('handlerPostRoot');
86 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
87
88 this.setResponseType(this.responseTypes, req, res, ctx);
89 await this.ingestBody(req, res, ctx);
90
91 await this.manager.postRoot(req, res, ctx);
92 }
93
94
95 /**
96 * @param {http.ClientRequest} req
97 * @param {http.ServerResponse} res
98 * @param {object} ctx
99 */
100 async handlerGetRoot(req, res, ctx) {
101 const _scope = _fileScope('handlerGetRoot');
102 const responseTypes = [
103 Enum.ContentType.TextHTML,
104 ];
105 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
106
107 Dingus.setHeadHandler(req, res, ctx);
108
109 this.setResponseType(responseTypes, req, res, ctx);
110
111 await this.manager.getRoot(req, res, ctx);
112 }
113
114
115 /**
116 * @param {http.ClientRequest} req
117 * @param {http.ServerResponse} res
118 * @param {object} ctx
119 */
120 async handlerGetHealthcheck(req, res, ctx) {
121 const _scope = _fileScope('handlerGetHealthcheck');
122 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
123
124 Dingus.setHeadHandler(req, res, ctx);
125
126 this.setResponseType(this.responseTypes, req, res, ctx);
127
128 await this.manager.getHealthcheck(res, ctx);
129 }
130
131
132 /**
133 * @param {http.ClientRequest} req
134 * @param {http.ServerResponse} res
135 * @param {object} ctx
136 */
137 async handlerGetInfo(req, res, ctx) {
138 const _scope = _fileScope('handlerGetInfo');
139 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
140
141 const responseTypes = [...this.responseTypes, Enum.ContentType.ImageSVG];
142
143 Dingus.setHeadHandler(req, res, ctx);
144
145 this.setResponseType(responseTypes, req, res, ctx);
146
147 await this.manager.getInfo(res, ctx);
148 }
149
150
151 /**
152 * @param {http.ClientRequest} req
153 * @param {http.ServerResponse} res
154 * @param {object} ctx
155 */
156 async handlerGetAdminOverview(req, res, ctx) {
157 const _scope = _fileScope('handlerGetAdminOverview');
158 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
159
160 Dingus.setHeadHandler(req, res, ctx);
161
162 this.setResponseType(this.responseTypes, req, res, ctx);
163
164 await this.authenticator.required(req, res, ctx);
165
166 await this.manager.getAdminOverview(res, ctx);
167 }
168
169
170 /**
171 * @param {http.ClientRequest} req
172 * @param {http.ServerResponse} res
173 * @param {object} ctx
174 */
175 async handlerGetAdminTopicDetails(req, res, ctx) {
176 const _scope = _fileScope('handlerGetAdminTopicDetails');
177 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
178
179 Dingus.setHeadHandler(req, res, ctx);
180
181 this.setResponseType(this.responseTypes, req, res, ctx);
182
183 await this.authenticator.required(req, res, ctx);
184
185 await this.manager.getTopicDetails(res, ctx);
186 }
187
188
189 /**
190 * Same as super.ingestBody, but if no body was sent, do not parse (and
191 * thus avoid possible unsupported media type error).
192 * @param {http.ClientRequest} req
193 * @param {http.ServerResponse} res
194 * @param {Object} ctx
195 */
196 async maybeIngestBody(req, res, ctx) {
197 ctx.rawBody = await this.bodyData(req);
198 const contentType = Dingus.getRequestContentType(req);
199 if (ctx.rawBody) {
200 this.parseBody(contentType, ctx);
201 }
202 }
203
204
205 /**
206 * @param {http.ClientRequest} req
207 * @param {http.ServerResponse} res
208 * @param {Object} ctx
209 */
210 async handlerUpdateTopic(req, res, ctx) {
211 const _scope = _fileScope('handlerUpdateTopic');
212 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
213
214 this.setResponseType(this.responseTypes, req, res, ctx);
215
216 await this.authenticator.required(req, res, ctx);
217
218 await this.maybeIngestBody(req, res, ctx);
219 ctx.method = req.method;
220 await this.manager.updateTopic(res, ctx);
221 }
222
223
224 /**
225 * @param {http.ClientRequest} req
226 * @param {http.ServerResponse} res
227 * @param {Object} ctx
228 */
229 async handlerUpdateSubscription(req, res, ctx) {
230 const _scope = _fileScope('handlerUpdateSubscription');
231 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
232
233 this.setResponseType(this.responseTypes, req, res, ctx);
234
235 await this.authenticator.required(req, res, ctx);
236
237 await this.maybeIngestBody(req, res, ctx);
238 ctx.method = req.method;
239 await this.manager.updateSubscription(res, ctx);
240 }
241
242
243 /**
244 * @param {http.ClientRequest} req
245 * @param {http.ServerResponse} res
246 * @param {object} ctx
247 */
248 async handlerGetStaticFile(req, res, ctx, file) {
249 const _scope = _fileScope('handlerGetStaticFile');
250 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx, file });
251
252 Dingus.setHeadHandler(req, res, ctx);
253
254 // Set a default response type to handle any errors; will be re-set to serve actual static content type.
255 this.setResponseType(this.responseTypes, req, res, ctx);
256
257 await this.serveFile(req, res, ctx, this.staticPath, file || ctx.params.file);
258 this.logger.info(_scope, 'finished', { ctx: { ...ctx, responseBody: common.logTruncate((ctx.responseBody || '').toString(), 100) } });
259 }
260
261
262 /**
263 * @param {http.ClientRequest} req
264 * @param {http.ServerResponse} res
265 * @param {object} ctx
266 */
267 async handlerPostAdminProcess(req, res, ctx) {
268 const _scope = _fileScope('handlerPostAdminProcess');
269 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
270
271 this.setResponseType(this.responseTypes, req, res, ctx);
272
273 await this.authenticator.required(req, res, ctx);
274
275 await this.manager.processTasks(res, ctx);
276 }
277 }
278
279 module.exports = Service;