Initial release
[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 * Same as super.ingestBody, but if no body was send, do not parse (and
190 * thus avoid possible unsupported media type error).
191 * @param {http.ClientRequest} req
192 * @param {http.ServerResponse} res
193 * @param {Object} ctx
194 */
195 async maybeIngestBody(req, res, ctx) {
196 ctx.rawBody = await this.bodyData(req);
197 const contentType = Dingus.getRequestContentType(req);
198 if (ctx.rawBody) {
199 this.parseBody(contentType, ctx);
200 }
201 }
202
203
204 /**
205 * @param {http.ClientRequest} req
206 * @param {http.ServerResponse} res
207 * @param {Object} ctx
208 */
209 async handlerUpdateTopic(req, res, ctx) {
210 const _scope = _fileScope('handlerUpdateTopic');
211 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
212
213 this.setResponseType(this.responseTypes, req, res, ctx);
214
215 await this.authenticator.required(req, res, ctx);
216
217 await this.maybeIngestBody(req, res, ctx);
218 ctx.method = req.method;
219 await this.manager.updateTopic(res, ctx);
220 }
221
222
223 /**
224 * @param {http.ClientRequest} req
225 * @param {http.ServerResponse} res
226 * @param {Object} ctx
227 */
228 async handlerUpdateSubscription(req, res, ctx) {
229 const _scope = _fileScope('handlerUpdateSubscription');
230 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
231
232 this.setResponseType(this.responseTypes, req, res, ctx);
233
234 await this.authenticator.required(req, res, ctx);
235
236 await this.maybeIngestBody(req, res, ctx);
237 ctx.method = req.method;
238 await this.manager.updateSubscription(res, ctx);
239 }
240
241
242 /**
243 * @param {http.ClientRequest} req
244 * @param {http.ServerResponse} res
245 * @param {object} ctx
246 */
247 async handlerGetStaticFile(req, res, ctx, file) {
248 const _scope = _fileScope('handlerGetStaticFile');
249 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx, file });
250
251 Dingus.setHeadHandler(req, res, ctx);
252
253 // Set a default response type to handle any errors; will be re-set to serve actual static content type.
254 this.setResponseType(this.responseTypes, req, res, ctx);
255
256 await this.serveFile(req, res, ctx, this.staticPath, file || ctx.params.file);
257 this.logger.info(_scope, 'finished', { ctx });
258 }
259
260
261 /**
262 * @param {http.ClientRequest} req
263 * @param {http.ServerResponse} res
264 * @param {object} ctx
265 */
266 async handlerPostAdminProcess(req, res, ctx) {
267 const _scope = _fileScope('handlerPostAdminProcess');
268 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
269
270 this.setResponseType(this.responseTypes, req, res, ctx);
271
272 await this.authenticator.required(req, res, ctx);
273
274 await this.manager.processTasks(res, ctx);
275 }
276 }
277
278 module.exports = Service;