Merge branch 'v1.3-dev' as v1.3.11
[websub-hub] / src / manager.js
index 0f110682eb2b3f94ddd1fe0076dab22e620ce69a..74730c27be142a5ac33e467fccec182d61bcefde 100644 (file)
@@ -25,13 +25,6 @@ class Manager {
     this.db = db;
     this.options = options;
     this.communication = new Communication(logger, db, options);
-
-    // Precalculate the invariant root GET metadata.
-    this.getRootContent = Template.rootHTML(undefined, options);
-    const now = new Date();
-    this.startTimeString = now.toGMTString();
-    this.startTimeMs = now.getTime();
-    this.getRootETag = common.generateETag(undefined, undefined, this.getRootContent);
   }
 
 
@@ -53,6 +46,7 @@ class Manager {
 
   /**
    * GET request for root.
+   * @param {http.ClientRequest} req
    * @param {http.ServerResponse} res
    * @param {object} ctx
    */
@@ -60,16 +54,8 @@ class Manager {
     const _scope = _fileScope('getRoot');
     this.logger.debug(_scope, 'called', { ctx });
 
-    res.setHeader(Enum.Header.LastModified, this.startTimeString);
-    res.setHeader(Enum.Header.ETag, this.getRootETag);
-
-    if (common.isClientCached(req, this.startTimeMs, this.getRootETag)) {
-      this.logger.debug(_scope, 'client cached response', { ctx });
-      res.statusCode = 304;
-      res.end();
-      return;
-    }
-    res.end(this.getRootContent);
+    const content = Template.rootHTML(ctx, this.options);
+    res.end(content);
     this.logger.info(_scope, 'finished', { ctx });
   }
 
@@ -557,8 +543,81 @@ class Manager {
       ctx.count = count.count;
     });
 
-    res.end(this.infoContent(ctx));
-    this.logger.info(_scope, 'finished', { ...ctx });
+    const content = this.infoContent(ctx);
+    res.setHeader(Enum.Header.ETag, common.generateETag(undefined, undefined, content));
+    res.setHeader(Enum.Header.CacheControl, 'no-cache');
+    res.end(content);
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * label the bars of the topic update history graph
+   * @param {Number} index
+   * @param {Number} value
+   * @returns {String}
+   */
+  static _historyBarCaption(index, value) {
+    let when;
+    switch (index) {
+      case 0:
+        when ='today';
+        break;
+      case 1:
+        when = 'yesterday';
+        break;
+      default:
+        when = `${index} days ago`;
+    }
+    return `${when}, ${value ? value : 'no'} update${value === 1 ? '': 's'}`;
+  }
+
+
+  /**
+   * GET SVG chart of topic update history
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async getHistorySVG(res, ctx) {
+    const _scope = _fileScope('getHistorySVG');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    const days = Math.min(parseInt(ctx.queryParams.days) || this.options.manager.publishHistoryDays, 365);
+    const histOptions = {
+      title: 'Topic Publish History',
+      description: 'Updates per Day',
+      labelZero: '^ Today',
+      labelX: 'Days Ago',
+      maxItems: days,
+      minItems: days,
+      tickEvery: 7,
+      barWidth: 25,
+      barHeight: 40,
+      labelHeight: 12,
+      barCaptionFn: Manager._historyBarCaption,
+    };
+
+    let publishHistory;
+    await this.db.context(async (dbCtx) => {
+      publishHistory = await this.db.topicPublishHistory(dbCtx, ctx.params.topicId, days);
+    });
+
+    res.end(Template.histogramSVG(publishHistory, histOptions));
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * Determine if a profile url matches enough of a topic url to describe control over it.
+   * Topic must match hostname and start with the profile's path.
+   * @param {URL} profileUrlObj
+   * @param {URL} topicUrlObj
+   * @returns {Boolean}
+   */
+  static _profileControlsTopic(profileUrlObj, topicUrlObj) {
+    const hostnameMatches = profileUrlObj.hostname === topicUrlObj.hostname;
+    const pathIsPrefix = topicUrlObj.pathname.startsWith(profileUrlObj.pathname);
+    return hostnameMatches && pathIsPrefix;
   }
 
 
@@ -576,8 +635,17 @@ class Manager {
     });
     this.logger.debug(_scope, 'got topics', { topics: ctx.topics });
 
+    // Profile users can only see related topics.
+    if (ctx.session && ctx.session.authenticatedProfile) {
+      const profileUrlObj = new URL(ctx.session.authenticatedProfile);
+      ctx.topics = ctx.topics.filter((topic) => {
+        const topicUrlObj = new URL(topic.url);
+        return Manager._profileControlsTopic(profileUrlObj, topicUrlObj);
+      });
+    }
+
     res.end(Template.adminOverviewHTML(ctx, this.options));
-    this.logger.info(_scope, 'finished', { ...ctx, topics: ctx.topics.length })
+    this.logger.info(_scope, 'finished', { ctx, topics: ctx.topics.length });
   }
 
 
@@ -590,15 +658,32 @@ class Manager {
     const _scope = _fileScope('getTopicDetails');
     this.logger.debug(_scope, 'called', { ctx });
 
+    ctx.publishSpan = 60; // FIXME: configurable
     const topicId = ctx.params.topicId;
+    let publishHistory;
     await this.db.context(async (dbCtx) => {
       ctx.topic = await this.db.topicGetById(dbCtx, topicId);
       ctx.subscriptions = await this.db.subscriptionsByTopicId(dbCtx, topicId);
+      publishHistory = await this.db.topicPublishHistory(dbCtx, topicId, ctx.publishSpan);
     });
-    this.logger.debug(_scope, 'got topic details', { topic: ctx.topic, subscriptions: ctx.subscriptions });
+    ctx.publishCount = publishHistory.reduce((a, b) => a + b, 0);
+    ctx.subscriptionsDelivered = ctx.subscriptions.filter((subscription) => {
+      return subscription.latestContentDelivered >= ctx.topic.contentUpdated;
+    }).length;
+    this.logger.debug(_scope, 'got topic details', { topic: ctx.topic, subscriptions: ctx.subscriptions, updates: ctx.publishCount });
+
+    // Profile users can only see related topics.
+    if (ctx.session && ctx.session.authenticatedProfile) {
+      const profileUrlObj = new URL(ctx.session.authenticatedProfile);
+      const topicUrlObj = new URL(ctx.topic.url);
+      if (!Manager._profileControlsTopic(profileUrlObj, topicUrlObj)) {
+        ctx.topic = null;
+        ctx.subscriptions = [];
+      }
+    }
 
     res.end(Template.adminTopicDetailsHTML(ctx, this.options));
-    this.logger.info(_scope, 'finished', { ...ctx, subscriptions: ctx.subscriptions.length, topic: ctx.topic.id });
+    this.logger.info(_scope, 'finished', { ctx, subscriptions: ctx.subscriptions.length, topic: ctx.topic && ctx.topic.id || ctx.topic });
   }
 
 
@@ -626,6 +711,8 @@ class Manager {
           await this.db.topicDeleted(txCtx, topicId);
           res.end();
           this.logger.info(_scope, 'topic set deleted', { ctx, topicId });
+          // Attempt to remove from db if no active subscriptions.
+          await this.db.topicPendingDelete(txCtx, topicId);
           return;
         }
 
@@ -770,7 +857,7 @@ class Manager {
    * @param {object} ctx
    */
   async processTasks(res, ctx) {
-    const _scope = _fileScope('getTopicDetails');
+    const _scope = _fileScope('processTasks');
     this.logger.debug(_scope, 'called', { ctx });
 
     // N.B. no await on this