From c4ea36cfde9ce5594b3a9392056d02390bb201b1 Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Tue, 16 Aug 2022 15:44:51 +0200 Subject: [PATCH] Added member attribution script (#15242) refs https://github.com/TryGhost/Team/issues/1804 - Adds a script that is only injected when the member attribution alpha flag is enabled - This script builds a history and saves it in localStorage as `ghost-history` that contains something like this: ``` json [ { "time": 1660650730, "path": "/about/" }, { "time": 1660651730, "path": "/welcome/" } ] ``` - Keeps track of the time of every page visit, so we can correctly remove old items. I also considered saving the time separately and clearing the whole history when the saved time is older than 24h, but that would have the side effect that items older than 24h might leak into the history if you visit every 12 hours (to give an example). Plus, having objects in the history might make it easier to add other attributes to the items if we ever want to do that in the future. We also have access to the time between visits. - Added `.eslintrc` configuration for this new frontend script. This makes it easier to spot errors when developing, and follow the same syntax rules as other scripts. In the future it can allow us to require an older ECMA version in the browser script. If we like this pattern, we could also use it for other frontend scripts. --- ghost/core/.eslintignore | 1 + ghost/core/core/bridge.js | 2 + .../core/core/frontend/helpers/ghost_head.js | 4 + .../member-attribution-assets/index.js | 4 + .../member-attribution-assets/service.js | 83 +++++++++++++++++ .../frontend/src/member-attribution/.eslintrc | 10 +++ .../member-attribution/member-attribution.js | 90 +++++++++++++++++++ ghost/core/core/frontend/web/site.js | 3 + 8 files changed, 197 insertions(+) create mode 100644 ghost/core/core/frontend/services/member-attribution-assets/index.js create mode 100644 ghost/core/core/frontend/services/member-attribution-assets/service.js create mode 100644 ghost/core/core/frontend/src/member-attribution/.eslintrc create mode 100644 ghost/core/core/frontend/src/member-attribution/member-attribution.js diff --git a/ghost/core/.eslintignore b/ghost/core/.eslintignore index 05fb9f4691..b3ba5ae236 100644 --- a/ghost/core/.eslintignore +++ b/ghost/core/.eslintignore @@ -1,3 +1,4 @@ core/frontend/src/**/*.js +!core/frontend/src/member-attribution/*.js core/frontend/public/**/*.js core/server/lib/members/static/auth/**/*.js diff --git a/ghost/core/core/bridge.js b/ghost/core/core/bridge.js index 4316908d11..c3e611d1c5 100644 --- a/ghost/core/core/bridge.js +++ b/ghost/core/core/bridge.js @@ -19,6 +19,7 @@ const appService = require('./frontend/services/apps'); const cardAssetService = require('./frontend/services/card-assets'); const commentCountsAssetService = require('./frontend/services/comment-counts-assets'); const adminAuthAssetService = require('./frontend/services/admin-auth-assets'); +const memberAttributionAssetService = require('./frontend/services/member-attribution-assets'); const routerManager = require('./frontend/services/routing').routerManager; const settingsCache = require('./shared/settings-cache'); const urlService = require('./server/services/url'); @@ -70,6 +71,7 @@ class Bridge { // rebuild asset files await commentCountsAssetService.load(); await adminAuthAssetService.load(); + await memberAttributionAssetService.load(); } catch (err) { logging.error(new errors.InternalServerError({ message: tpl(messages.activateFailed, {theme: loadedTheme.name}), diff --git a/ghost/core/core/frontend/helpers/ghost_head.js b/ghost/core/core/frontend/helpers/ghost_head.js index 83dee1863b..b3bbebc61b 100644 --- a/ghost/core/core/frontend/helpers/ghost_head.js +++ b/ghost/core/core/frontend/helpers/ghost_head.js @@ -233,6 +233,10 @@ module.exports = async function ghost_head(options) { // eslint-disable-line cam head.push(``); } + if (labs.isSet('memberAttribution')) { + head.push(``); + } + if (!_.isEmpty(globalCodeinjection)) { head.push(globalCodeinjection); } diff --git a/ghost/core/core/frontend/services/member-attribution-assets/index.js b/ghost/core/core/frontend/services/member-attribution-assets/index.js new file mode 100644 index 0000000000..04c663f300 --- /dev/null +++ b/ghost/core/core/frontend/services/member-attribution-assets/index.js @@ -0,0 +1,4 @@ +const MemberAttributionAssetsService = require('./service'); +const memberAttributionAssets = new MemberAttributionAssetsService(); + +module.exports = memberAttributionAssets; diff --git a/ghost/core/core/frontend/services/member-attribution-assets/service.js b/ghost/core/core/frontend/services/member-attribution-assets/service.js new file mode 100644 index 0000000000..51a2851817 --- /dev/null +++ b/ghost/core/core/frontend/services/member-attribution-assets/service.js @@ -0,0 +1,83 @@ +// const debug = require('@tryghost/debug')('comments-counts-assets'); +const Minifier = require('@tryghost/minifier'); +const path = require('path'); +const fs = require('fs').promises; +const logging = require('@tryghost/logging'); +const config = require('../../../shared/config'); + +class MemberAttributionAssetsService { + constructor(options = {}) { + /** @private */ + this.src = options.src || path.join(config.get('paths').assetSrc, 'member-attribution'); + /** @private */ + this.dest = options.dest || config.getContentPath('public'); + /** @private */ + this.minifier = new Minifier({src: this.src, dest: this.dest}); + } + + /** + * @private + */ + generateGlobs() { + return { + 'member-attribution.min.js': '*.js' + }; + } + + /** + * @private + */ + generateReplacements() { + return {}; + } + + /** + * @private + * @returns {Promise} + */ + async minify(globs, options) { + try { + await this.minifier.minify(globs, options); + } catch (error) { + if (error.code === 'EACCES') { + logging.error('Ghost was not able to write member-attribution asset files due to permissions.'); + return; + } + + throw error; + } + } + + /** + * @private + * @returns {Promise} + */ + async clearFiles() { + const rmFile = async (name) => { + await fs.unlink(path.join(this.dest, name)); + }; + + const promises = []; + for (const key of Object.keys(this.generateGlobs())) { + // @deprecated switch this to use fs.rm when we drop support for Node v12 + promises.push(rmFile(key)); + } + + // We don't care if removing these files fails as it's valid for them to not exist + await Promise.allSettled(promises); + } + + /** + * Minify, move into the destination directory, and clear existing asset files. + * + * @returns {Promise} + */ + async load() { + const globs = this.generateGlobs(); + const replacements = this.generateReplacements(); + await this.clearFiles(); + await this.minify(globs, {replacements}); + } +} + +module.exports = MemberAttributionAssetsService; diff --git a/ghost/core/core/frontend/src/member-attribution/.eslintrc b/ghost/core/core/frontend/src/member-attribution/.eslintrc new file mode 100644 index 0000000000..097425f5c3 --- /dev/null +++ b/ghost/core/core/frontend/src/member-attribution/.eslintrc @@ -0,0 +1,10 @@ +{ + "extends": "../../../../.eslintrc.js", + "env": { + "browser": true, + "node": false + }, + "rules": { + "no-console": "off" + } +} diff --git a/ghost/core/core/frontend/src/member-attribution/member-attribution.js b/ghost/core/core/frontend/src/member-attribution/member-attribution.js new file mode 100644 index 0000000000..ac945fba24 --- /dev/null +++ b/ghost/core/core/frontend/src/member-attribution/member-attribution.js @@ -0,0 +1,90 @@ +// Location where we want to store the history in localStorage +const STORAGE_KEY = 'ghost-history'; + +// How long before an item should expire (24h) +const TIMEOUT = 24 * 60 * 60 * 1000; + +// Maximum amount of urls in the history +const LIMIT = 15; + +// History is saved in JSON format, from old to new +// Time is saved to be able to exclude old items +// [ +// { +// "time": 12341234, +// "path": "/about/" +// }, +// { +// "time": 12341235, +// "path": "/welcome/" +// } +// ] + +(async function () { + try { + const storage = window.localStorage; + const historyString = storage.getItem(STORAGE_KEY); + const currentTime = new Date().getTime(); + + // Append current location + let history = []; + + if (historyString) { + try { + history = JSON.parse(historyString); + } catch (error) { + // Ignore invalid JSON, ans clear history + console.warn('[Member Attribution] Error while parsing history', error); + } + } + + // Remove all items that are expired + const firstNotExpiredIndex = history.findIndex((item) => { + // Return true to keep all items after and including this item + // Return false to remove the item + + if (!item.time || typeof item.time !== 'number') { + return false; + } + + const difference = currentTime - item.time; + + if (isNaN(item.time) || difference > TIMEOUT) { + // Expired or invalid + return false; + } + + // Valid item (so all following items are also valid by definition) + return true; + }); + + if (firstNotExpiredIndex > 0) { + // Remove until the first valid item + history.splice(0, firstNotExpiredIndex); + } else if (firstNotExpiredIndex === -1) { + // Not a single valid item found, remove all + history = []; + } + + const currentPath = window.location.pathname; + + if (history.length === 0 || history[history.length - 1].path !== currentPath) { + history.push({ + path: currentPath, + time: currentTime + }); + } else if (history.length > 0) { + history[history.length - 1].time = currentTime; + } + + // Restrict length + if (history.length > LIMIT) { + history = history.slice(-LIMIT); + } + + // Save current timestamp + storage.setItem(STORAGE_KEY, JSON.stringify(history)); + } catch (error) { + console.error('[Member Attribution] Failed with error', error); + } +})(); diff --git a/ghost/core/core/frontend/web/site.js b/ghost/core/core/frontend/web/site.js index 4fedf5bba1..44c05fd8ee 100644 --- a/ghost/core/core/frontend/web/site.js +++ b/ghost/core/core/frontend/web/site.js @@ -75,6 +75,9 @@ module.exports = function setupSiteApp(routerConfig) { // Comment counts siteApp.use(mw.servePublicFile('built', 'public/comment-counts.min.js', 'application/javascript', constants.ONE_YEAR_S)); + // Member attribution + siteApp.use(mw.servePublicFile('built', 'public/member-attribution.min.js', 'application/javascript', constants.ONE_YEAR_S)); + // Serve blog images using the storage adapter siteApp.use(STATIC_IMAGE_URL_PREFIX, mw.handleImageSizes, storage.getStorage('images').serve()); // Serve blog media using the storage adapter