From 5e5b90ac29cdee4bb9b04dd1b5a8588e97b4fbf5 Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Thu, 16 Nov 2017 13:03:24 +0000 Subject: [PATCH] Added Url Service to track all URLs in the system (#9247) refs #9192 - Introduces a url service that can be initialised - Added a concept of Resources and resource config.json that contains details about the resources in the system that we may want to make customisable - Note that individual resources know how to create their own Urls... this is important for later - Url Service loads all of the resources, and stores their URLs - The UrlService binds to all events, so that when a resource changes its url and related data can be updated if needed - There is a temporary config guard so that this can be turned off easily --- .eslintrc.json | 5 +- core/server/index.js | 6 +- core/server/services/url/Resource.js | 52 +++++++++++ core/server/services/url/UrlService.js | 122 +++++++++++++++++++++++++ core/server/services/url/cache.js | 39 ++++++++ core/server/services/url/config.json | 55 +++++++++++ core/server/services/url/index.js | 32 +++++++ 7 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 core/server/services/url/Resource.js create mode 100644 core/server/services/url/UrlService.js create mode 100644 core/server/services/url/cache.js create mode 100644 core/server/services/url/config.json create mode 100644 core/server/services/url/index.js diff --git a/.eslintrc.json b/.eslintrc.json index 09e87328b2..8c9ed96d00 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -13,7 +13,10 @@ ], "array-callback-return": "off", "array-element-newline": "off", - "arrow-body-style": "error", + "arrow-body-style": [ + "error", + "always" + ], "arrow-parens": [ "error", "always" diff --git a/core/server/index.js b/core/server/index.js index d672dc1fbc..8364d1559b 100644 --- a/core/server/index.js +++ b/core/server/index.js @@ -28,6 +28,7 @@ var debug = require('ghost-ignition').debug('boot:init'), utils = require('./utils'), // Services that need initialisation + urlService = require('./services/url'), apps = require('./services/apps'), xmlrpc = require('./services/xmlrpc'), slack = require('./services/slack'); @@ -62,7 +63,10 @@ function init() { // Initialize xmrpc ping xmlrpc.listen(), // Initialize slack ping - slack.listen() + slack.listen(), + // Url Service + urlService.init() + ); }).then(function () { debug('Apps, XMLRPC, Slack done'); diff --git a/core/server/services/url/Resource.js b/core/server/services/url/Resource.js new file mode 100644 index 0000000000..5a130b8b8a --- /dev/null +++ b/core/server/services/url/Resource.js @@ -0,0 +1,52 @@ +'use strict'; + +const _ = require('lodash'), + api = require('../../api'), + utils = require('../../utils'), + prefetchDefaults = { + context: { + internal: true + }, + limit: 'all' + }; + +class Resource { + constructor(config) { + this.name = config.name; + this.api = config.api; + this.prefetchOptions = config.prefetchOptions || {}; + this.urlLookup = config.urlLookup || config.name; + this.events = config.events; + this.items = {}; + } + + fetchAll() { + const options = _.defaults(this.prefetchOptions, prefetchDefaults); + + return api[this.api] + .browse(options) + .then((resp) => { + this.items = resp[this.api]; + return this.items; + }); + } + + toUrl(item) { + const data = { + [this.urlLookup]: item + }; + return utils.url.urlFor(this.urlLookup, data); + } + + toData(item) { + return { + slug: item.slug, + resource: { + type: this.name, + id: item.id + } + }; + } +} + +module.exports = Resource; diff --git a/core/server/services/url/UrlService.js b/core/server/services/url/UrlService.js new file mode 100644 index 0000000000..3b2b991f8c --- /dev/null +++ b/core/server/services/url/UrlService.js @@ -0,0 +1,122 @@ +'use strict'; + +/** + * # URL Service + * + * This file defines a class of URLService, which serves as a centralised place to handle + * generating, storing & fetching URLs of all kinds. + */ + +const _ = require('lodash'), + Promise = require('bluebird'), + _debug = require('ghost-ignition').debug._base, + debug = _debug('ghost:services:url'), + events = require('../../events'), + // TODO: make this dynamic + resourceConfig = require('./config.json'), + Resource = require('./Resource'), + urlCache = require('./cache'), + utils = require('../../utils'); + +class UrlService { + constructor() { + this.resources = []; + + _.each(resourceConfig, (config) => { + this.resources.push(new Resource(config)); + }); + } + + bind() { + const eventHandlers = { + add(model, resource) { + UrlService.cacheResourceItem(resource, model.toJSON()); + }, + update(model, resource) { + const newItem = model.toJSON(); + const oldItem = model.updatedAttributes(); + + const oldUrl = resource.toUrl(oldItem); + const storedData = urlCache.get(oldUrl); + + const newUrl = resource.toUrl(newItem); + const newData = resource.toData(newItem); + + debug('update', oldUrl, newUrl); + if (oldUrl && oldUrl !== newUrl && storedData) { + // CASE: we are updating a cached item and the URL has changed + debug('Changing URL, unset first'); + urlCache.unset(oldUrl); + } + + // CASE: the URL is either new, or the same, this will create or update + urlCache.set(newUrl, newData); + }, + + remove(model, resource) { + const url = resource.toUrl(model.toJSON()); + urlCache.unset(url); + }, + + reload(model, resource) { + // @TODO: get reload working, so that permalink changes are reflected + // NOTE: the current implementation of sitemaps doesn't have this + debug('Need to reload all resources: ' + resource.name); + } + }; + + _.each(this.resources, (resource) => { + _.each(resource.events, (method, eventName) => { + events.on(eventName, (model) => { + eventHandlers[method].call(this, model, resource, eventName); + }); + }); + }); + } + + fetchAll() { + return Promise.each(this.resources, (resource) => { + return resource.fetchAll(); + }); + } + + loadResourceUrls() { + debug('load start'); + + this.fetchAll() + .then(() => { + debug('load end, start processing'); + + _.each(this.resources, (resource) => { + _.each(resource.items, (item) => { + UrlService.cacheResourceItem(resource, item); + }); + }); + + debug('processing done, url cache built. Number urls', _.size(urlCache.getAll())); + // Wrap this in a check, because else this is a HUGE amount of output + // To output this, use DEBUG=ghost:*,ghost-url + if (_debug.enabled('ghost-url')) { + debug('url-cache', require('util').inspect(urlCache.getAll(), false, null)); + } + }) + .catch((err) => { + debug('load error', err); + }); + } + + static cacheResourceItem(resource, item) { + const url = resource.toUrl(item); + const data = resource.toData(item); + + urlCache.set(url, data); + } + + static cacheRoute(relativeUrl, data) { + const url = utils.url.urlFor({relativeUrl: relativeUrl}); + data.static = true; + urlCache.set(url, data); + } +} + +module.exports = UrlService; diff --git a/core/server/services/url/cache.js b/core/server/services/url/cache.js new file mode 100644 index 0000000000..e925f84ae2 --- /dev/null +++ b/core/server/services/url/cache.js @@ -0,0 +1,39 @@ +'use strict'; +// Based heavily on the settings cache +const _ = require('lodash'), + debug = require('ghost-ignition').debug('services:url:cache'), + events = require('../../events'), + urlCache = {}; + +module.exports = { + /** + * Get the entire cache object + * Uses clone to prevent modifications from being reflected + * @return {{}} urlCache + */ + getAll() { + return _.cloneDeep(urlCache); + }, + set(key, value) { + const existing = this.get(key); + + if (!existing) { + debug('adding url', key); + urlCache[key] = _.cloneDeep(value); + events.emit('url.added', key, value); + } else if (!_.isEqual(value, existing)) { + debug('overwriting url', key); + urlCache[key] = _.cloneDeep(value); + events.emit('url.edited', key, value); + } + }, + unset(key) { + const value = this.get(key); + delete urlCache[key]; + debug('removing url', key); + events.emit('url.removed', key, value); + }, + get(key) { + return _.cloneDeep(urlCache[key]); + } +}; diff --git a/core/server/services/url/config.json b/core/server/services/url/config.json new file mode 100644 index 0000000000..9a65e5f6ab --- /dev/null +++ b/core/server/services/url/config.json @@ -0,0 +1,55 @@ +[ + { + "name": "post", + "api" : "posts", + "prefetchOptions": { + "filter": "visibility:public+status:published+page:false", + "include": "author,tags" + }, + "events": { + "post.published": "add", + "post.published.edited": "update", + "post.unpublished": "remove", + "settings.permalinks.edited": "reload" + } + }, + { + "name": "page", + "api" : "posts", + "prefetchOptions": { + "filter": "visibility:public+status:published+page:true", + "include": "author,tags" + }, + "urlLookup": "post", + "events": { + "page.published": "add", + "page.published.edited": "update", + "page.unpublished": "remove" + } + }, + { + "name": "tag", + "api" : "tags", + "prefetchOptions": { + "filter": "visibility:public" + }, + "events": { + "tag.added": "add", + "tag.edited": "update", + "tag.deleted": "remove" + + } + }, + { + "name": "author", + "api" : "users", + "prefetchOptions": { + "filter": "visibility:public" + }, + "events": { + "user.activated": "add", + "user.activated.edited": "update", + "user.deactivated": "remove" + } + } +] diff --git a/core/server/services/url/index.js b/core/server/services/url/index.js new file mode 100644 index 0000000000..e22519d153 --- /dev/null +++ b/core/server/services/url/index.js @@ -0,0 +1,32 @@ +const debug = require('ghost-ignition').debug('services:url:init'), + config = require('../../config'), + events = require('../../events'), + UrlService = require('./UrlService'); + +// @TODO we seriously should move this or make it do almost nothing... +module.exports.init = function init() { + // Temporary config value just in case this causes problems + // @TODO delete this + if (config.get('disableUrlService')) { + return; + } + + // Kick off the constructor + const urlService = new UrlService(); + + urlService.bind(); + + // Hardcoded routes + // @TODO figure out how to do this from channel or other config + // @TODO get rid of name concept (for compat with sitemaps) + UrlService.cacheRoute('/', {name: 'home'}); + // @TODO figure out how to do this from apps + // @TODO only do this if subscribe is enabled! + UrlService.cacheRoute('/subscribe/', {}); + + // Register a listener for server-start to load all the known urls + events.on('server:start', function loadAllUrls() { + debug('URL service, loading all URLS'); + urlService.loadResourceUrls(); + }); +};