'<a href="https://git.squeep.com/?p=websub-hub;a=tree">Development Repository</a> / <a href="https://github.com/thylacine/websub-hub/">GitHub mirror</a>',
'<span class="copyright">©<time datetime="2022">ⅯⅯⅩⅩⅠⅠ</time></span>',
],
+ publishHistoryDays: 60, // Number of days of update history to show on topic details page
strictSecrets: false, // If true, reject requests with secrets but not over https
publicHub: true, // Accept publish requests as new topics.
processImmediately: true, // If true, immediately attempt to process requests when accepted.
}
+ /**
+ * Return an array of the counts of the last #days of topic updates.
+ * @param {*} dbCtx
+ * @param {*} topicId
+ * @param {Number} days
+ */
+ async topicPublishHistory(dbCtx, topicId, days) {
+ this._notImplemented('topicPublishHistory', arguments);
+ }
+
+
/**
* Create or update the basic parameters of a topic.
* @param {*} dbCtx
/**
* Updates a topic's content data and content update timestamp.
* @param {Object} data
- * @param {Integer} data.topicId
+ * @param {*} data.topicId
* @param {String} data.content
* @param {String} data.contentHash
* @param {String=} data.contentType
}
+ async topicPublishHistory(dbCtx, topicId, days) {
+ const _scope = _fileScope('topicPublishHistory');
+ this.logger.debug(_scope, 'called', { topicId, days });
+
+ const events = await dbCtx.manyOrNone(this.statement.topicPublishHistory, { topicIds: [topicId], daysAgo: days });
+ const history = Array.from({ length: days }, () => 0);
+ events.forEach(({ daysAgo, contentUpdates }) => history[daysAgo] = contentUpdates);
+
+ return history;
+ }
+
+
async topicSet(dbCtx, data) {
const _scope = _fileScope('topicSet');
this.logger.debug(_scope, 'called', data);
--- /dev/null
+--
+SELECT
+ EXTRACT(DAY FROM now() - content_updated::date) AS days_ago,
+ count(*) AS content_updates
+FROM topic_content_history
+WHERE
+ (now() - content_updated::date) < ($(daysAgo) * INTERVAL '1 day')
+AND
+ ($(topicIds) IS NULL OR topic_id = ANY($(topicIds)::uuid[]))
+GROUP BY content_updated::date
+ORDER BY content_updated::date
+;
}
+ topicPublishHistory(dbCtx, topicId, days) {
+ const _scope = _fileScope('topicPublishHistory');
+ this.logger.debug(_scope, 'called', { topicId, days })
+
+ const events = this.statement.topicPublishHistory.all({ topicId, daysAgo: days });
+ const history = Array.from({ length: days }, () => 0);
+ events.forEach(({ daysAgo, contentUpdates }) => history[daysAgo] = contentUpdates);
+
+ return history;
+ }
+
+
topicSet(dbCtx, data) {
const _scope = _fileScope('topicSet');
this.logger.debug(_scope, 'called', data);
--- /dev/null
+--
+SELECT
+ (strftime('%s', 'now') - content_updated) / 86400 AS days_ago,
+ count(*) AS content_updates
+FROM topic_content_history
+WHERE
+ days_ago <= :daysAgo
+AND
+ (:topicId IS NULL OR topic_id = :topicId)
+GROUP BY days_ago
+ORDER BY days_ago
+;
});
res.end(this.infoContent(ctx));
- this.logger.info(_scope, 'finished', { ...ctx });
+ 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('getHist');
+ 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 });
+ }
+
/**
* GET request for authorized /admin information.
* @param {http.ServerResponse} res
}
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 });
}
const _scope = _fileScope('getTopicDetails');
this.logger.debug(_scope, 'called', { ctx });
+
+ ctx.publishSpan = 60;
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);
+ 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) {
}
res.end(Template.adminTopicDetailsHTML(ctx, this.options));
- this.logger.info(_scope, 'finished', { ...ctx, subscriptions: ctx.subscriptions.length, topic: ctx.topic && ctx.topic.id || ctx.topic });
+ this.logger.info(_scope, 'finished', { ctx, subscriptions: ctx.subscriptions.length, topic: ctx.topic && ctx.topic.id || ctx.topic });
}
this.on(['GET', 'HEAD'], '/admin', this.handlerRedirect.bind(this), `${options.dingus.proxyPrefix}/admin/`);
this.on(['GET', 'HEAD'], '/admin/', this.handlerGetAdminOverview.bind(this));
this.on(['GET', 'HEAD'], '/admin/topic/:topicId', this.handlerGetAdminTopicDetails.bind(this));
+ this.on(['GET', 'HEAD'], '/admin/topic/:topicId/history.svg', this.handlerGetHistorySVG.bind(this));
// Private data-editing endpoints
this.on(['PATCH', 'DELETE'], '/admin/topic/:topicId', this.handlerUpdateTopic.bind(this));
}
+ async handlerGetHistorySVG(req, res, ctx) {
+ const _scope = _fileScope('handlerGetHist');
+ this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+ const responseTypes = [Enum.ContentType.ImageSVG];
+
+ Service.setHeadHandler(req, res, ctx);
+
+ this.setResponseType(responseTypes, req, res, ctx);
+
+ await this.manager.getHistorySVG(res, ctx);
+ }
+
+
/**
* @param {http.ClientRequest} req
* @param {http.ServerResponse} res
` </tbody>
</table>
</section>`,
+ ` <section class="history">
+ <p>Topic Publish History — ${ctx.publishCount} updates in the last ${ctx.publishSpan} days</p>
+ <img title="Topic Publish History" src="${ctx.params.topicId}/history.svg">
+ </section>`,
` <section class="subscriptions">
<p>${ctx.subscriptions.length ? ctx.subscriptions.length : 'no'} subscription${(ctx.subscriptions.length === 1) ? '' : 's'}</p>
<table>
'use strict';
-function escapeXml(s) {
- if (typeof s === 'number') {
- return s;
- } else if (typeof s !== 'string') {
- return undefined;
- } else {
- return s
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
- }
-}
+const th = require('./template-helper');
const ctxDefaults = {
* renderer from https://github.com/badges/shields/tree/master/badge-maker which
* is under the http://creativecommons.org/publicdomain/zero/1.0/ license.
*/
- return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${ctx.width}" height="${ctx.height}" role="img" aria-label="${escapeXml(ctx.accessibleText)}">
- <title>${escapeXml(ctx.accessibleText)}</title>
+ return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${ctx.width}" height="${ctx.height}" role="img" aria-label="${th.xmlEscape(ctx.accessibleText)}">
+ <title>${th.xmlEscape(ctx.accessibleText)}</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
<stop offset=".1" stop-color="#aaa" stop-opacity=".1"/>
<rect width="${ctx.width}" height="${ctx.height}" fill="url(#s)"/>
</g>
<g fill="${ctx.color}" text-anchor="left" font-family="${ctx.fontFamily}" text-rendering="geometricPrecision" font-size="11" font-weight="bold">
- <text x="${ctx.halfCharWidth}" y="${ctx.verticalMargin}">${escapeXml(ctx.label)}</text>
- <text x="${fixedRound(ctx.halfCharWidth + ctx.labelWidth)}" y="${ctx.verticalMargin}">${escapeXml(ctx.message)}</text>
+ <text x="${ctx.halfCharWidth}" y="${ctx.verticalMargin}">${th.xmlEscape(ctx.label)}</text>
+ <text x="${fixedRound(ctx.halfCharWidth + ctx.labelWidth)}" y="${ctx.verticalMargin}">${th.xmlEscape(ctx.message)}</text>
</g>
</svg>`;
};
--- /dev/null
+'use strict';
+
+const th = require('./template-helper');
+
+const optionsDefaults = {
+ barWidth: 20,
+ barHeight: 100,
+ scaleBars: true,
+ barCaptionFn: () => '',
+ labelZero: undefined,
+ labelX: undefined,
+ labelHeight: 8,
+ fontFamily: 'DejaVu Sans,Verdana,Geneva,sans-serif',
+ frameColor: 'gray',
+ tickEvery: undefined,
+ tickHeight: 4,
+ tickColor: 'gray',
+ minItems: 0,
+ maxItems: undefined,
+};
+
+function grey(percent) {
+ const value = Math.round(95 * (1.0 - percent));
+ return `rgb(${value}%, ${value}%, ${value}%)`;
+}
+
+function svgHeader(options, width, height) {
+ return [
+ `<svg
+version="1.1"
+baseProfile="full"
+xmlns="http://www.w3.org/2000/svg"
+xmlns:xlink="http://www.w3.org/1999/xlink"
+xmlns:ev="http://www.w3.org/2001/xml-events"
+width="${width}px" height="${height}px"
+viewBox="0 0 ${width} ${height}"
+preserveAspectRatio="xMinYMin meet"`,
+ options.accessibleText ? ` role="img" aria-label="${th.xmlEscape(options.accessibleText)}"` : '',
+ '>',
+ options.title ? `<title>${th.xmlEscape(options.title)}</title>` : '',
+ options.description ? `<desc>${th.xmlEscape(options.description)}</desc>` : '',
+ '\n',
+ ];
+}
+
+function svgFrame(options, width) {
+ if (!options.frameColor) {
+ return '';
+ }
+ return `\t<g id="frame">
+\t\t<rect x="0.5px" y="0.5px" width="${width - 1}px" height="${options.barHeight - 1}px" fill="none" stroke="${options.frameColor}" />
+\t</g>\n`;
+}
+
+function svgTicks(options, width) {
+ if (!options.tickEvery) {
+ return '';
+ }
+ const tickSpacing = options.tickEvery * options.barWidth;
+ const tickOffset = options.barWidth / 2;
+ const numTicks = Math.ceil(width / tickSpacing);
+ const ticks = Array.from({ length: numTicks }, (_, i) => i)
+ .map((index) => `M ${(index * tickSpacing) + tickOffset} ${options.barHeight - 2} v ${options.tickHeight}`)
+ .join(' ');
+
+ return `\t<g id="ticks">
+\t<path d="${ticks}" stroke="${options.tickColor}" fill="none" stroke-width="0.5px" />
+\t</g>\n`;
+}
+
+function svgLabels(options, width, height) {
+ const labels = [];
+ if (!options.labelHeight) {
+ return labels;
+ }
+ labels.push(`\t<g font-size="${options.labelHeight}px" font-family="${options.fontFamily}" font-variant="small-caps">\n`);
+ const y = height - (options.labelHeight / 2) + 2;
+ if (options.labelZero) {
+ labels.push(`\t\t<text font-size="${options.labelHeight}px" text-anchor="start" x="0" y="${y}">${options.labelZero}</text>\n`);
+ }
+ if (options.labelX) {
+ labels.push(`\t\t<text text-anchor="middle" x="${width / 2}" y="${y}">${options.labelX}</text>\n`);
+ }
+ labels.push('\t</g>\n');
+ return labels;
+}
+
+function svgFooter() {
+ return '</svg>';
+}
+
+function svgBar(options, value, index, maxValue) {
+ const id = `i${index}`;
+ const x = options.barWidth * index;
+ const width = options.barWidth;
+ const height = options.barHeight;
+ const scale = value / Math.max(1, maxValue);
+ const scaleHeight = options.scaleBars ? height * scale : height;
+ const yOffset = height - scaleHeight;
+ const fill = grey(scale);
+ const emptyFill = grey(0);
+ const title = th.xmlEscape(options.barCaptionFn(index, value));
+ return [
+ ...(options.scaleBars && [
+ `\t<rect id="${id}" x="${x}" y="0" width="${width}" height="${height}" fill="${emptyFill}">`,
+ ...(title && `<title>${title}</title>`),
+ '\t</rect>\n',
+ ]),
+ `\t<rect id="${id}" x="${x}" y="${yOffset}" width="${width}" height="${scaleHeight}" fill="${fill}">`,
+ ...(title && `<title>${title}</title>`),
+ '\t</rect>\n',
+ ].join('');
+}
+
+module.exports = (items, options = {}) => {
+ options = Object.assign({}, optionsDefaults, options);
+
+ const maxValue = items.reduce((a, b) => Math.max(a, b), 0);
+ const hasLabel = !!options.labelX || !!options.labelZero;
+ const height = options.barHeight + 2 + (hasLabel ? options.labelHeight + 2 : 0);
+ const width = Math.max(items.length, options.minItems) * options.barWidth;
+
+ return [
+ ...svgHeader(options, width, height),
+ ...items.slice(0, options.maxItems).map((value, index) => svgBar(options, value, index, maxValue)),
+ svgFrame(options, width),
+ svgTicks(options, width),
+ ...svgLabels(options, width, height),
+ svgFooter(),
+ ].join('');
+};
\ No newline at end of file
adminOverviewHTML: require('./admin-overview-html'),
adminTopicDetailsHTML: require('./admin-topic-details-html'),
badgeSVG: require('./badge-svg'),
+ histogramSVG: require('./histogram-svg'),
rootHTML: require('./root-html'),
};
\ No newline at end of file
const { TemplateHelper } = require('@squeep/html-template-helper');
-
/**
* Render a topic as a row of details.
* @param {Object} topic
}
+/**
+ * Escape some xml things in strings.
+ * @param {String} string
+ */
+function xmlEscape(string) {
+ if (typeof string === 'number') {
+ return string;
+ }
+ if (typeof string !== 'string') {
+ return undefined;
+ }
+ // eslint-disable-next-line security/detect-object-injection
+ return string.replace(/[<>&'"]/, (c) => ({
+ '<': '<',
+ '>': '>',
+ '&': '&',
+ '\'': ''',
+ '"': '"',
+ }[c]));
+}
+
module.exports = Object.assign(Object.create(TemplateHelper), {
+ xmlEscape,
renderTopicRowHeader,
renderTopicRow,
renderSubscriptionRowHeader,
}
.topics {}
.subscriptions {}
+.history img {
+ width: 100%;
+}
.external {
background-image: url("external-link.svg");
background-position: right center;
db._topicSetDataValidate(data);
});
it('covers invalid value', function () {
- data.leaseSecondsPreferred = -100;
- try {
- db._topicSetDataValidate(data);
- assert.fail('did not get expected exception');
- } catch (e) {
- assert(e instanceof DBErrors.DataValidation);
- }
+ data.leaseSecondsPreferred = -100;
+ try {
+ db._topicSetDataValidate(data);
+ assert.fail('did not get expected exception');
+ } catch (e) {
+ assert(e instanceof DBErrors.DataValidation);
+ }
});
it('covers invalid range', function () {
data.leaseSecondsPreferred = 10000;
assert(db);
});
+ it('is healthy', async function () {
+ const result = await db.healthCheck();
+ assert(result);
+ });
+
describe('Authentication', function () {
let identifier, credential;
beforeEach(function () {
assert.strictEqual(Number(topic.contentFetchAttemptsSinceSuccess), 0);
});
});
+ step('gets publish history', async function () {
+ await db.context(async (dbCtx) => {
+ const result = (await db.topicPublishHistory(dbCtx, topicId, 7))
+ .map((x) => Number(x));
+ const expected = [1, 0, 0, 0, 0, 0, 0];
+ assert.deepStrictEqual(result, expected);
+ });
+ });
step('deletes a topic', async function () {
await db.context(async (dbCtx) => {
const result = await db.topicSet(dbCtx, testData.anotherTopicSet);
});
});
+ describe('topicPublishHistory', function () {
+ beforeEach(function () {
+ sinon.stub(db.db, 'manyOrNone');
+ });
+ it('success', async function () {
+ db.db.manyOrNone.returns([
+ { daysAgo: 1, contentUpdates: 1 },
+ { daysAgo: 3, contentUpdates: 2 },
+ ]);
+ const result = await db.topicPublishHistory(dbCtx, topicId, 7);
+ const expected = [0, 1, 0, 2, 0, 0, 0];
+ assert.deepStrictEqual(result, expected);
+ });
+ }); // topicPublishHistory
+
describe('topicSet', function () {
let data;
beforeEach(function () {
assert.deepStrictEqual(e, expected);
}
});
- });
+ }); // subscriptionDeleteExpired
describe('subscriptionDeliveryClaim', function () {
it('success', async function () {
}
assert(db.statement.topicDeleteById.run.called);
});
- });
+ }); // topicPendingDelete
+
+ describe('topicPublishHistory', function () {
+ beforeEach(function () {
+ sinon.stub(db.statement.topicPublishHistory, 'all');
+ });
+ it('success', function () {
+ db.statement.topicPublishHistory.all.returns([
+ { daysAgo: 1, contentUpdates: 1 },
+ { daysAgo: 3, contentUpdates: 2 },
+ ]);
+ const result = db.topicPublishHistory(dbCtx, topicId, 7);
+ const expected = [0, 1, 0, 2, 0, 0, 0];
+ assert.deepStrictEqual(result, expected);
+ });
+ }); // topicPublishHistory
describe('topicSet', function () {
let data;
};
ctx = {
params: {},
+ queryParams: {},
};
manager = new Manager(stubLogger, stubDb, options);
sinon.stub(manager.communication, 'verificationProcess');
});
}); // getInfo
+ describe('_historyBarCaption', function () {
+ it('covers today, none', function () {
+ const result = Manager._historyBarCaption(0, 0);
+ assert.strictEqual(result, 'today, no updates');
+ });
+ it('covers yesterday, singular', function () {
+ const result = Manager._historyBarCaption(1, 1);
+ assert.strictEqual(result, 'yesterday, 1 update');
+ });
+ it('covers older, plural', function () {
+ const result = Manager._historyBarCaption(7, 3);
+ assert.strictEqual(result, '7 days ago, 3 updates');
+ });
+ }); // _historyBarCaption
+
+ describe('getHistorySVG', function () {
+ beforeEach(function () {
+ manager.db.topicPublishHistory.resolves([0, 1, 2, 1, 0, 1, 2, 0, 1]);
+ });
+ it('covers', async function () {
+ await manager.getHistorySVG(res, ctx);
+ assert(res.end.called);
+ });
+ }); // getHistorySVG
+
describe('getAdminOverview', function () {
beforeEach(function () {
manager.db.topicGetAll.resolves([
deliveryAttemptsSinceSuccess: 0,
deliveryNextAttempt: new Date(-Infinity),
}]);
+ manager.db.topicPublishHistory.resolves([0, 1, 0, 1, 0]);
});
it('covers', async function() {
await manager.getTopicDetails(res, ctx);
}); // handlerGetHealthcheck
describe('handlerGetInfo', function () {
- it('covers', async function() {
+ it('covers', async function () {
await service.handlerGetInfo(req, res, ctx);
assert(service.manager.getInfo.called);
});
}); // handlerGetInfo
+ describe('handlerGetHistorySVG', function () {
+ it('covers', async function () {
+ await service.handlerGetHistorySVG(req, res, ctx);
+ assert(service.manager.getHistorySVG.called);
+ });
+ }); // handlerGetHistorySVG
+
describe('handlerGetAdminOverview', function () {
it('covers', async function () {
await service.handlerGetAdminOverview(req, res, ctx);
beforeEach(function () {
ctx = {
+ params: {
+ topicId: '97dd5488-a303-11ec-97ab-0025905f714a',
+ },
topic: {},
subscriptions: [
{},
--- /dev/null
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const template = require('../../../src/template/histogram-svg');
+
+describe('Histogram SVG Template', function () {
+ let items, options;
+ beforeEach(function () {
+ items = [];
+ options = {};
+ });
+ it('covers defaults', function () {
+ const result = template(items, options);
+ assert(result);
+ });
+ it('covers options', function () {
+ items = [0, 1, 2];
+ options = {
+ labelX: 'Days Ago',
+ labelZero: 'Today',
+ tickEvery: 2,
+ frameColor: undefined,
+ labelHeight: 0,
+ };
+ const result = template(items, options);
+ assert(result);
+ });
+});
\ No newline at end of file
});
}); // renderSubscriptionRowHeader
+ describe('xmlEscape', function () {
+ it('ignores numbers', function () {
+ const result = th.xmlEscape(3);
+ assert.strictEqual(result, 3);
+ });
+ it('ignores objects', function () {
+ const result = th.xmlEscape({});
+ assert.strictEqual(result, undefined);
+ });
+ it('escapes a thing', function () {
+ const result = th.xmlEscape('&');
+ assert.strictEqual(result, '&');
+ });
+ }); // xmlEscape
+
});
'topicGetByUrl',
'topicGetContentById',
'topicPendingDelete',
+ 'topicPublishHistory',
'topicSet',
'topicSetContent',
'topicUpdate',