mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-22 18:31:57 +03:00
efd1587ee9
refs #9834 - @TODO: the test env eslint needs to use the plugin, not part of this PR
424 lines
13 KiB
JavaScript
424 lines
13 KiB
JavaScript
const debug = require('ghost-ignition').debug('services:url:resources'),
|
|
Promise = require('bluebird'),
|
|
_ = require('lodash'),
|
|
Resource = require('./Resource'),
|
|
config = require('../../config'),
|
|
models = require('../../models'),
|
|
common = require('../../lib/common');
|
|
|
|
/**
|
|
* These are the default resources and filters.
|
|
* These are the minimum filters for public accessibility of resources.
|
|
*/
|
|
const resourcesConfig = [
|
|
{
|
|
type: 'posts',
|
|
modelOptions: {
|
|
modelName: 'Post',
|
|
filter: 'visibility:public+status:published+page:false',
|
|
exclude: [
|
|
'title',
|
|
'mobiledoc',
|
|
'html',
|
|
'plaintext',
|
|
'amp',
|
|
'codeinjection_head',
|
|
'codeinjection_foot',
|
|
'meta_title',
|
|
'meta_description',
|
|
'custom_excerpt',
|
|
'og_image',
|
|
'og_title',
|
|
'og_description',
|
|
'twitter_image',
|
|
'twitter_title',
|
|
'twitter_description',
|
|
'custom_template',
|
|
'feature_image',
|
|
'locale'
|
|
],
|
|
withRelated: ['tags', 'authors'],
|
|
withRelatedPrimary: {
|
|
primary_tag: 'tags',
|
|
primary_author: 'authors'
|
|
},
|
|
withRelatedFields: {
|
|
tags: ['tags.id', 'tags.slug'],
|
|
authors: ['users.id', 'users.slug']
|
|
}
|
|
},
|
|
events: {
|
|
add: 'post.published',
|
|
update: 'post.published.edited',
|
|
remove: 'post.unpublished'
|
|
}
|
|
},
|
|
{
|
|
type: 'pages',
|
|
modelOptions: {
|
|
modelName: 'Post',
|
|
exclude: [
|
|
'title',
|
|
'mobiledoc',
|
|
'html',
|
|
'plaintext',
|
|
'amp',
|
|
'codeinjection_head',
|
|
'codeinjection_foot',
|
|
'meta_title',
|
|
'meta_description',
|
|
'custom_excerpt',
|
|
'og_image',
|
|
'og_title',
|
|
'og_description',
|
|
'twitter_image',
|
|
'twitter_title',
|
|
'twitter_description',
|
|
'custom_template',
|
|
'feature_image',
|
|
'locale',
|
|
'tags',
|
|
'authors',
|
|
'primary_tag',
|
|
'primary_author'
|
|
],
|
|
filter: 'visibility:public+status:published+page:true'
|
|
},
|
|
events: {
|
|
add: 'page.published',
|
|
update: 'page.published.edited',
|
|
remove: 'page.unpublished'
|
|
}
|
|
},
|
|
{
|
|
type: 'tags',
|
|
keep: ['id', 'slug', 'updated_at', 'created_at'],
|
|
modelOptions: {
|
|
modelName: 'Tag',
|
|
exclude: [
|
|
'description',
|
|
'meta_title',
|
|
'meta_description'
|
|
],
|
|
filter: 'visibility:public'
|
|
},
|
|
events: {
|
|
add: 'tag.added',
|
|
update: 'tag.edited',
|
|
remove: 'tag.deleted'
|
|
}
|
|
},
|
|
{
|
|
type: 'users',
|
|
modelOptions: {
|
|
modelName: 'User',
|
|
exclude: [
|
|
'bio',
|
|
'website',
|
|
'location',
|
|
'facebook',
|
|
'twitter',
|
|
'accessibility',
|
|
'meta_title',
|
|
'meta_description',
|
|
'tour'
|
|
],
|
|
filter: 'visibility:public'
|
|
},
|
|
events: {
|
|
add: 'user.activated',
|
|
update: 'user.activated.edited',
|
|
remove: 'user.deactivated'
|
|
}
|
|
}
|
|
];
|
|
|
|
/**
|
|
* NOTE: We are querying knex directly, because the Bookshelf ORM overhead is too slow.
|
|
*/
|
|
class Resources {
|
|
constructor(queue) {
|
|
this.queue = queue;
|
|
this.data = {};
|
|
|
|
this.listeners = [];
|
|
this._listeners();
|
|
}
|
|
|
|
_listenOn(eventName, listener) {
|
|
this.listeners.push({
|
|
eventName: eventName,
|
|
listener: listener
|
|
});
|
|
|
|
common.events.on(eventName, listener);
|
|
}
|
|
|
|
_listeners() {
|
|
/**
|
|
* We fetch the resources as early as possible.
|
|
* Currently the url service needs to use the settings cache,
|
|
* because we need to `settings.permalink`.
|
|
*/
|
|
this._listenOn('db.ready', this._onDatabaseReady.bind(this));
|
|
}
|
|
|
|
_onDatabaseReady() {
|
|
const ops = [];
|
|
debug('db ready. settings cache ready.');
|
|
|
|
_.each(resourcesConfig, (resourceConfig) => {
|
|
this.data[resourceConfig.type] = [];
|
|
ops.push(this._fetch(resourceConfig));
|
|
|
|
this._listenOn(resourceConfig.events.add, (model) => {
|
|
return this._onResourceAdded.bind(this)(resourceConfig.type, model);
|
|
});
|
|
|
|
this._listenOn(resourceConfig.events.update, (model) => {
|
|
return this._onResourceUpdated.bind(this)(resourceConfig.type, model);
|
|
});
|
|
|
|
this._listenOn(resourceConfig.events.remove, (model) => {
|
|
return this._onResourceRemoved.bind(this)(resourceConfig.type, model);
|
|
});
|
|
});
|
|
|
|
Promise.all(ops)
|
|
.then(() => {
|
|
// CASE: all resources are fetched, start the queue
|
|
this.queue.start({
|
|
event: 'init',
|
|
tolerance: 100,
|
|
requiredSubscriberCount: 1
|
|
});
|
|
});
|
|
}
|
|
|
|
_fetch(resourceConfig, options = {offset: 0, limit: 999}) {
|
|
debug('_fetch', resourceConfig.type, resourceConfig.modelOptions);
|
|
|
|
let modelOptions = _.cloneDeep(resourceConfig.modelOptions);
|
|
const isSQLite = config.get('database:client') === 'sqlite3';
|
|
|
|
// CASE: prevent "too many SQL variables" error on SQLite3
|
|
if (isSQLite) {
|
|
modelOptions.offset = options.offset;
|
|
modelOptions.limit = options.limit;
|
|
}
|
|
|
|
return models.Base.Model.raw_knex.fetchAll(modelOptions)
|
|
.then((objects) => {
|
|
debug('fetched', resourceConfig.type, objects.length);
|
|
|
|
_.each(objects, (object) => {
|
|
this.data[resourceConfig.type].push(new Resource(resourceConfig.type, object));
|
|
});
|
|
|
|
if (objects.length && isSQLite) {
|
|
options.offset = options.offset + options.limit;
|
|
return this._fetch(resourceConfig, {offset: options.offset, limit: options.limit});
|
|
}
|
|
});
|
|
}
|
|
|
|
_onResourceAdded(type, model) {
|
|
const resourceConfig = _.find(resourcesConfig, {type: type});
|
|
const exclude = resourceConfig.modelOptions.exclude;
|
|
const withRelatedFields = resourceConfig.modelOptions.withRelatedFields;
|
|
const obj = _.omit(model.toJSON(), exclude);
|
|
|
|
if (withRelatedFields) {
|
|
_.each(withRelatedFields, (fields, key) => {
|
|
if (!obj[key]) {
|
|
return;
|
|
}
|
|
|
|
obj[key] = _.map(obj[key], (relation) => {
|
|
const relationToReturn = {};
|
|
|
|
_.each(fields, (field) => {
|
|
const fieldSanitized = field.replace(/^\w+./, '');
|
|
relationToReturn[fieldSanitized] = relation[fieldSanitized];
|
|
});
|
|
|
|
return relationToReturn;
|
|
});
|
|
});
|
|
|
|
const withRelatedPrimary = resourceConfig.modelOptions.withRelatedPrimary;
|
|
|
|
if (withRelatedPrimary) {
|
|
_.each(withRelatedPrimary, (relation, primaryKey) => {
|
|
if (!obj[primaryKey] || !obj[relation]) {
|
|
return;
|
|
}
|
|
|
|
const targetTagKeys = Object.keys(obj[relation].find((item) => {
|
|
return item.id === obj[primaryKey].id;
|
|
}));
|
|
obj[primaryKey] = _.pick(obj[primaryKey], targetTagKeys);
|
|
});
|
|
}
|
|
}
|
|
|
|
const resource = new Resource(type, obj);
|
|
|
|
debug('_onResourceAdded', type);
|
|
this.data[type].push(resource);
|
|
|
|
this.queue.start({
|
|
event: 'added',
|
|
action: 'added:' + model.id,
|
|
eventData: {
|
|
id: model.id,
|
|
type: type
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* CASE:
|
|
* - post was fetched on bootstrap
|
|
* - that means, the post is already published
|
|
* - resource exists, but nobody owns it
|
|
* - if the model changes, it can be that somebody will then own the post
|
|
*
|
|
* CASE:
|
|
* - post was fetched on bootstrap
|
|
* - that means, the post is already published
|
|
* - resource exists and is owned by somebody
|
|
* - but the data changed and is maybe no longer owned?
|
|
* - e.g. featured:false changes and your filter requires featured posts
|
|
*/
|
|
_onResourceUpdated(type, model) {
|
|
debug('_onResourceUpdated', type);
|
|
|
|
this.data[type].every((resource) => {
|
|
if (resource.data.id === model.id) {
|
|
const resourceConfig = _.find(resourcesConfig, {type: type});
|
|
const exclude = resourceConfig.modelOptions.exclude;
|
|
const withRelatedFields = resourceConfig.modelOptions.withRelatedFields;
|
|
const obj = _.omit(model.toJSON(), exclude);
|
|
|
|
if (withRelatedFields) {
|
|
_.each(withRelatedFields, (fields, key) => {
|
|
if (!obj[key]) {
|
|
return;
|
|
}
|
|
|
|
obj[key] = _.map(obj[key], (relation) => {
|
|
const relationToReturn = {};
|
|
|
|
_.each(fields, (field) => {
|
|
const fieldSanitized = field.replace(/^\w+./, '');
|
|
relationToReturn[fieldSanitized] = relation[fieldSanitized];
|
|
});
|
|
|
|
return relationToReturn;
|
|
});
|
|
});
|
|
|
|
const withRelatedPrimary = resourceConfig.modelOptions.withRelatedPrimary;
|
|
|
|
if (withRelatedPrimary) {
|
|
_.each(withRelatedPrimary, (relation, primaryKey) => {
|
|
if (!obj[primaryKey] || !obj[relation]) {
|
|
return;
|
|
}
|
|
|
|
const targetTagKeys = Object.keys(obj[relation].find((item) => {
|
|
return item.id === obj[primaryKey].id;
|
|
}));
|
|
obj[primaryKey] = _.pick(obj[primaryKey], targetTagKeys);
|
|
});
|
|
}
|
|
}
|
|
|
|
resource.update(obj);
|
|
|
|
// CASE: pretend it was added
|
|
if (!resource.isReserved()) {
|
|
this.queue.start({
|
|
event: 'added',
|
|
action: 'added:' + model.id,
|
|
eventData: {
|
|
id: model.id,
|
|
type: type
|
|
}
|
|
});
|
|
}
|
|
|
|
// break!
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
_onResourceRemoved(type, model) {
|
|
let index = null;
|
|
let resource;
|
|
|
|
this.data[type].every((_resource, _index) => {
|
|
if (_resource.data.id === model._previousAttributes.id) {
|
|
resource = _resource;
|
|
index = _index;
|
|
// break!
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
// CASE: there are possible cases that the resource was not fetched e.g. visibility is internal
|
|
if (index === null) {
|
|
debug('can\'t find resource', model._previousAttributes.id);
|
|
return;
|
|
}
|
|
|
|
this.data[type].splice(index, 1);
|
|
resource.remove();
|
|
}
|
|
|
|
getAll() {
|
|
return this.data;
|
|
}
|
|
|
|
getAllByType(type) {
|
|
return this.data[type];
|
|
}
|
|
|
|
getByIdAndType(type, id) {
|
|
return _.find(this.data[type], {data: {id: id}});
|
|
}
|
|
|
|
reset() {
|
|
_.each(this.listeners, (obj) => {
|
|
common.events.removeListener(obj.eventName, obj.listener);
|
|
});
|
|
|
|
this.listeners = [];
|
|
this.data = {};
|
|
}
|
|
|
|
softReset() {
|
|
this.data = {};
|
|
|
|
_.each(resourcesConfig, (resourceConfig) => {
|
|
this.data[resourceConfig.type] = [];
|
|
});
|
|
}
|
|
|
|
releaseAll() {
|
|
_.each(this.data, (resources, type) => {
|
|
_.each(this.data[type], (resource) => {
|
|
resource.release();
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = Resources;
|