Initial release
[websub-hub] / src / service.js
diff --git a/src/service.js b/src/service.js
new file mode 100644 (file)
index 0000000..dfba6b8
--- /dev/null
@@ -0,0 +1,278 @@
+'use strict';
+
+/**
+ * Here we extend the base API server to define our routes and any route-specific
+ * behavior (middlewares) before handing off to the manager.
+ */
+
+const { Dingus } = require('@squeep/api-dingus');
+const common = require('./common');
+const Enum = require('./enum');
+const Manager = require('./manager');
+const Authenticator = require('./authenticator');
+const path = require('path');
+
+const _fileScope = common.fileScope(__filename);
+
+class Service extends Dingus {
+  constructor(logger, db, options) {
+    super(logger, {
+      ...options.dingus,
+      ignoreTrailingSlash: false,
+    });
+
+    this.manager = new Manager(logger, db, options);
+    this.authenticator = new Authenticator(logger, db, options);
+    this.staticPath = path.join(__dirname, '..', 'static');
+
+    // Primary API endpoint
+    this.on('POST', '/', this.handlerPostRoot.bind(this));
+
+    // Information page about service
+    this.on(['GET', 'HEAD'], '/', this.handlerGetRoot.bind(this));
+
+    // Give load-balancers something to check
+    this.on(['GET', 'HEAD'], '/healthcheck', this.handlerGetHealthcheck.bind(this));
+
+    // Public information about topics
+    this.on('GET', '/info', this.handlerGetInfo.bind(this));
+    this.on('GET', '/info/', this.handlerGetInfo.bind(this));
+
+    // These routes are intended for accessing static content during development.
+    // In production, a proxy server would likely handle these first.
+    this.on(['GET', 'HEAD'], '/static', (req, res, ctx) => this.handlerRedirect(req, res, ctx, `${options.dingus.proxyPrefix}/static/`));
+    this.on(['GET', 'HEAD'], '/static/', (req, res, ctx) => this.handlerGetStaticFile(req, res, ctx, 'index.html'));
+    this.on(['GET', 'HEAD'], '/static/:file', this.handlerGetStaticFile.bind(this));
+    this.on(['GET', 'HEAD'], '/favicon.ico', (req, res, ctx) => this.handlerGetStaticFile(req, res, ctx, 'favicon.ico'));
+    this.on(['GET', 'HEAD'], '/robots.txt', (req, res, ctx) => this.handlerGetStaticFile(req, res, ctx, 'robots.txt'));
+
+    // Private informational endpoints
+    this.on(['GET', 'HEAD'], '/admin', (req, res, ctx) => this.handlerRedirect(req, res, ctx, `${options.dingus.proxyPrefix}/admin/`));
+    this.on(['GET', 'HEAD'], '/admin/', this.handlerGetAdminOverview.bind(this));
+    this.on(['GET', 'HEAD'], '/admin/topic/:topicId', this.handlerGetAdminTopicDetails.bind(this));
+
+    // Private data-editing endpoints
+    this.on(['PATCH', 'DELETE'], '/admin/topic/:topicId', this.handlerUpdateTopic.bind(this));
+    this.on(['PATCH', 'DELETE'], '/admin/subscription/:subscriptionId', this.handlerUpdateSubscription.bind(this));
+
+    // Private server-action endpoints
+    this.on('POST', '/admin/process', this.handlerPostAdminProcess.bind(this));
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res 
+   * @param {Object} ctx 
+   * @param {String} newPath
+  */
+   async handlerRedirect(req, res, ctx, newPath) {
+    const _scope = _fileScope('handlerRedirect');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+    res.setHeader(Enum.Header.Location, newPath);
+    res.statusCode = 307; // Temporary Redirect
+    res.end();
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res 
+   * @param {object} ctx 
+   */
+  async handlerPostRoot(req, res, ctx) {
+    const _scope = _fileScope('handlerPostRoot');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+    await this.ingestBody(req, res, ctx);
+
+    await this.manager.postRoot(req, res, ctx);
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res 
+   * @param {object} ctx 
+   */
+  async handlerGetRoot(req, res, ctx) {
+    const _scope = _fileScope('handlerGetRoot');
+    const responseTypes = [
+      Enum.ContentType.TextHTML,
+    ];
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(responseTypes, req, res, ctx);
+
+    await this.manager.getRoot(req, res, ctx);
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res 
+   * @param {object} ctx 
+   */
+  async handlerGetHealthcheck(req, res, ctx) {
+    const _scope = _fileScope('handlerGetHealthcheck');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+  
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    await this.manager.getHealthcheck(res, ctx);
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async handlerGetInfo(req, res, ctx) {
+    const _scope = _fileScope('handlerGetInfo');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+    const responseTypes = [...this.responseTypes, Enum.ContentType.ImageSVG];
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(responseTypes, req, res, ctx);
+
+    await this.manager.getInfo(res, ctx);
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async handlerGetAdminOverview(req, res, ctx) {
+    const _scope = _fileScope('handlerGetAdminOverview');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    await this.authenticator.required(req, res, ctx);
+
+    await this.manager.getAdminOverview(res, ctx);
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async handlerGetAdminTopicDetails(req, res, ctx) {
+    const _scope = _fileScope('handlerGetAdminTopicDetails');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    await this.authenticator.required(req, res, ctx);
+
+    await this.manager.getTopicDetails(res, ctx);
+  }
+
+  /**
+   * Same as super.ingestBody, but if no body was send, do not parse (and
+   * thus avoid possible unsupported media type error).
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async maybeIngestBody(req, res, ctx) {
+    ctx.rawBody = await this.bodyData(req);
+    const contentType = Dingus.getRequestContentType(req);
+    if (ctx.rawBody) {
+      this.parseBody(contentType, ctx);
+    }
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+   async handlerUpdateTopic(req, res, ctx) {
+    const _scope = _fileScope('handlerUpdateTopic');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    await this.authenticator.required(req, res, ctx);
+
+    await this.maybeIngestBody(req, res, ctx);
+    ctx.method = req.method;
+    await this.manager.updateTopic(res, ctx);
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async handlerUpdateSubscription(req, res, ctx) {
+  const _scope = _fileScope('handlerUpdateSubscription');
+  this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+  this.setResponseType(this.responseTypes, req, res, ctx);
+
+  await this.authenticator.required(req, res, ctx);
+
+  await this.maybeIngestBody(req, res, ctx);
+  ctx.method = req.method;
+  await this.manager.updateSubscription(res, ctx);
+}
+  
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async handlerGetStaticFile(req, res, ctx, file) {
+    const _scope = _fileScope('handlerGetStaticFile');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx, file });
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    // Set a default response type to handle any errors; will be re-set to serve actual static content type.
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    await this.serveFile(req, res, ctx, this.staticPath, file || ctx.params.file);
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async handlerPostAdminProcess(req, res, ctx) {
+    const _scope = _fileScope('handlerPostAdminProcess');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    await this.authenticator.required(req, res, ctx);
+
+    await this.manager.processTasks(res, ctx);
+  }
+}
+
+module.exports = Service;