mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-20 22:42:53 -05:00
1f37ff6053
refs #5422 - we can support null titles after this PR if we want - user model: fix getAuthorRole - user model: support adding roles by name - we support this for roles as well, this makes it easier when importing related user roles (because usually roles already exists in the database and the related id's are wrong e.g. roles_users) - base model: support for null created_at or updated_at values - post or tag slugs are always safe strings - enable an import of a null slug, no need to crash or to cover this on import layer - add new DataImporter logic - uses a class inheritance mechanism to achieve an easier readability and maintenance - schema validation (happens on model layer) was ignored - allow to import unknown user id's (see https://github.com/TryGhost/Ghost/issues/8365) - most of the duplication handling happens on model layer (we can use the power of unique fields and errors from the database) - the import is splitted into three steps: - beforeImport --> prepares the data to import, sorts out relations (roles, tags), detects fields (for LTS) - doImport --> does the actual import - afterImport --> updates the data after successful import e.g. update all user reference fields e.g. published_by (compares the imported data with the current state of the database) - import images: markdown can be null - show error message when json handler can't parse file - do not request gravatar if email is null - return problems/warnings after successful import - optimise warnings in importer - do not return warnings for role duplications, no helpful information - error handler: return context information of error - we show the affected json entries as one line in the UI - show warning for: detected duplicated tag - schema validation: fix valueMustBeBoolean translation - remove context property from json parse error
800 lines
31 KiB
JavaScript
800 lines
31 KiB
JavaScript
// # Post Model
|
|
var _ = require('lodash'),
|
|
uuid = require('uuid'),
|
|
moment = require('moment'),
|
|
Promise = require('bluebird'),
|
|
sequence = require('../utils/sequence'),
|
|
errors = require('../errors'),
|
|
legacyConverter = require('../utils/markdown-converter'),
|
|
htmlToText = require('html-to-text'),
|
|
ghostBookshelf = require('./base'),
|
|
events = require('../events'),
|
|
config = require('../config'),
|
|
utils = require('../utils'),
|
|
baseUtils = require('./base/utils'),
|
|
i18n = require('../i18n'),
|
|
Post,
|
|
Posts;
|
|
|
|
Post = ghostBookshelf.Model.extend({
|
|
|
|
tableName: 'posts',
|
|
|
|
emitChange: function emitChange(event, options) {
|
|
options = options || {};
|
|
|
|
var resourceType = this.get('page') ? 'page' : 'post';
|
|
|
|
if (options.usePreviousResourceType) {
|
|
resourceType = this.updated('page') ? 'page' : 'post';
|
|
}
|
|
|
|
events.emit(resourceType + '.' + event, this, options);
|
|
},
|
|
|
|
defaults: function defaults() {
|
|
return {
|
|
uuid: uuid.v4(),
|
|
status: 'draft'
|
|
};
|
|
},
|
|
|
|
/**
|
|
* We update the tags after the Post was inserted.
|
|
* We update the tags before the Post was updated, see `onSaving` event.
|
|
* `onCreated` is called before `onSaved`.
|
|
*/
|
|
onCreated: function onCreated(model, response, options) {
|
|
var status = model.get('status');
|
|
|
|
model.emitChange('added');
|
|
|
|
if (['published', 'scheduled'].indexOf(status) !== -1) {
|
|
model.emitChange(status, {importing: options.importing});
|
|
}
|
|
|
|
return this.updateTags(model, response, options);
|
|
},
|
|
|
|
/**
|
|
* http://knexjs.org/#Builder-forUpdate
|
|
* https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-reads.html
|
|
*
|
|
* Lock target collection/model for further update operations.
|
|
* This avoids collisions and possible content override cases.
|
|
*
|
|
* `forUpdate` is only supported for posts right now
|
|
*/
|
|
onFetching: function onFetching(model, columns, options) {
|
|
if (options.forUpdate && options.transacting) {
|
|
options.query.forUpdate();
|
|
}
|
|
},
|
|
|
|
onFetchingCollection: function onFetchingCollection(model, columns, options) {
|
|
if (options.forUpdate && options.transacting) {
|
|
options.query.forUpdate();
|
|
}
|
|
},
|
|
|
|
onUpdated: function onUpdated(model) {
|
|
model.statusChanging = model.get('status') !== model.updated('status');
|
|
model.isPublished = model.get('status') === 'published';
|
|
model.isScheduled = model.get('status') === 'scheduled';
|
|
model.wasPublished = model.updated('status') === 'published';
|
|
model.wasScheduled = model.updated('status') === 'scheduled';
|
|
model.resourceTypeChanging = model.get('page') !== model.updated('page');
|
|
model.publishedAtHasChanged = model.hasDateChanged('published_at');
|
|
model.needsReschedule = model.publishedAtHasChanged && model.isScheduled;
|
|
|
|
// Handle added and deleted for post -> page or page -> post
|
|
if (model.resourceTypeChanging) {
|
|
if (model.wasPublished) {
|
|
model.emitChange('unpublished', {usePreviousResourceType: true});
|
|
}
|
|
|
|
if (model.wasScheduled) {
|
|
model.emitChange('unscheduled', {usePreviousResourceType: true});
|
|
}
|
|
|
|
model.emitChange('deleted', {usePreviousResourceType: true});
|
|
model.emitChange('added');
|
|
|
|
if (model.isPublished) {
|
|
model.emitChange('published');
|
|
}
|
|
|
|
if (model.isScheduled) {
|
|
model.emitChange('scheduled');
|
|
}
|
|
} else {
|
|
if (model.statusChanging) {
|
|
// CASE: was published before and is now e.q. draft or scheduled
|
|
if (model.wasPublished) {
|
|
model.emitChange('unpublished');
|
|
}
|
|
|
|
// CASE: was draft or scheduled before and is now e.q. published
|
|
if (model.isPublished) {
|
|
model.emitChange('published');
|
|
}
|
|
|
|
// CASE: was draft or published before and is now e.q. scheduled
|
|
if (model.isScheduled) {
|
|
model.emitChange('scheduled');
|
|
}
|
|
|
|
// CASE: from scheduled to something
|
|
if (model.wasScheduled && !model.isScheduled && !model.isPublished) {
|
|
model.emitChange('unscheduled');
|
|
}
|
|
} else {
|
|
if (model.isPublished) {
|
|
model.emitChange('published.edited');
|
|
}
|
|
|
|
if (model.needsReschedule) {
|
|
model.emitChange('rescheduled');
|
|
}
|
|
}
|
|
|
|
// Fire edited if this wasn't a change between resourceType
|
|
model.emitChange('edited');
|
|
}
|
|
},
|
|
|
|
onDestroying: function onDestroying(model, options) {
|
|
return model.load('tags', options)
|
|
.then(function (response) {
|
|
if (!response.related || !response.related('tags') || !response.related('tags').length) {
|
|
return;
|
|
}
|
|
|
|
return Promise.mapSeries(response.related('tags').models, function (tag) {
|
|
return baseUtils.tagUpdate.detachTagFromPost(model, tag, options)();
|
|
});
|
|
})
|
|
.then(function () {
|
|
if (model.previous('status') === 'published') {
|
|
model.emitChange('unpublished');
|
|
}
|
|
|
|
model.emitChange('deleted');
|
|
});
|
|
},
|
|
|
|
onSaving: function onSaving(model, attr, options) {
|
|
options = options || {};
|
|
|
|
var self = this,
|
|
title,
|
|
i,
|
|
// Variables to make the slug checking more readable
|
|
newTitle = this.get('title'),
|
|
newStatus = this.get('status'),
|
|
olderStatus = this.previous('status'),
|
|
prevTitle = this._previousAttributes.title,
|
|
prevSlug = this._previousAttributes.slug,
|
|
tagsToCheck = this.get('tags'),
|
|
publishedAt = this.get('published_at'),
|
|
publishedAtHasChanged = this.hasDateChanged('published_at', {beforeWrite: true}),
|
|
mobiledoc = this.get('mobiledoc'),
|
|
tags = [], ops = [];
|
|
|
|
// CASE: disallow published -> scheduled
|
|
// @TODO: remove when we have versioning based on updated_at
|
|
if (newStatus !== olderStatus && newStatus === 'scheduled' && olderStatus === 'published') {
|
|
return Promise.reject(new errors.ValidationError({
|
|
message: i18n.t('errors.models.post.isAlreadyPublished', {key: 'status'})
|
|
}));
|
|
}
|
|
|
|
// CASE: both page and post can get scheduled
|
|
if (newStatus === 'scheduled') {
|
|
if (!publishedAt) {
|
|
return Promise.reject(new errors.ValidationError({
|
|
message: i18n.t('errors.models.post.valueCannotBeBlank', {key: 'published_at'})
|
|
}));
|
|
} else if (!moment(publishedAt).isValid()) {
|
|
return Promise.reject(new errors.ValidationError({
|
|
message: i18n.t('errors.models.post.valueCannotBeBlank', {key: 'published_at'})
|
|
}));
|
|
// CASE: to schedule/reschedule a post, a minimum diff of x minutes is needed (default configured is 2minutes)
|
|
} else if (
|
|
publishedAtHasChanged &&
|
|
moment(publishedAt).isBefore(moment().add(config.get('times').cannotScheduleAPostBeforeInMinutes, 'minutes')) &&
|
|
!options.importing
|
|
) {
|
|
return Promise.reject(new errors.ValidationError({
|
|
message: i18n.t('errors.models.post.expectedPublishedAtInFuture', {
|
|
cannotScheduleAPostBeforeInMinutes: config.get('times').cannotScheduleAPostBeforeInMinutes
|
|
})
|
|
}));
|
|
}
|
|
}
|
|
|
|
// If we have a tags property passed in
|
|
if (!_.isUndefined(tagsToCheck) && !_.isNull(tagsToCheck)) {
|
|
// and deduplicate upper/lowercase tags
|
|
_.each(tagsToCheck, function each(item) {
|
|
for (i = 0; i < tags.length; i = i + 1) {
|
|
if (tags[i].name.toLocaleLowerCase() === item.name.toLocaleLowerCase()) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
tags.push(item);
|
|
});
|
|
|
|
// keep tags for 'saved' event
|
|
// get('tags') will be removed after saving, because it's not a direct attribute of posts (it's a relation)
|
|
this.tagsToSave = tags;
|
|
}
|
|
|
|
ghostBookshelf.Model.prototype.onSaving.call(this, model, attr, options);
|
|
|
|
if (mobiledoc) {
|
|
this.set('html', utils.mobiledocConverter.render(JSON.parse(mobiledoc)));
|
|
} else {
|
|
// legacy markdown mode
|
|
this.set('html', legacyConverter.render(_.toString(this.get('markdown'))));
|
|
}
|
|
|
|
if (this.hasChanged('html') || !this.get('plaintext')) {
|
|
this.set('plaintext', htmlToText.fromString(this.get('html'), {
|
|
wordwrap: 80,
|
|
ignoreImage: true,
|
|
linkHrefBaseUrl: utils.url.urlFor('home').replace(/\/$/, ''),
|
|
hideLinkHrefIfSameAsText: true,
|
|
preserveNewlines: true,
|
|
returnDomByDefault: true,
|
|
uppercaseHeadings: false
|
|
}));
|
|
}
|
|
|
|
// disabling sanitization until we can implement a better version
|
|
if (!options.importing) {
|
|
title = this.get('title') || i18n.t('errors.models.post.untitled');
|
|
this.set('title', _.toString(title).trim());
|
|
}
|
|
|
|
// ### Business logic for published_at and published_by
|
|
// If the current status is 'published' and published_at is not set, set it to now
|
|
if (newStatus === 'published' && !publishedAt) {
|
|
this.set('published_at', new Date());
|
|
}
|
|
|
|
// If the current status is 'published' and the status has just changed ensure published_by is set correctly
|
|
if (newStatus === 'published' && this.hasChanged('status')) {
|
|
// unless published_by is set and we're importing, set published_by to contextUser
|
|
if (!(this.get('published_by') && options.importing)) {
|
|
this.set('published_by', this.contextUser(options));
|
|
}
|
|
} else {
|
|
// In any other case (except import), `published_by` should not be changed
|
|
if (this.hasChanged('published_by') && !options.importing) {
|
|
this.set('published_by', this.previous('published_by'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* - `updateTags` happens before the post is saved to the database
|
|
* - when editing a post, it's running in a transaction, see `Post.edit`
|
|
* - we are using a update collision detection, we have to know if tags were updated in the client
|
|
*
|
|
* NOTE: For adding a post, updateTags happens after the post insert, see `onCreated` event
|
|
*/
|
|
if (options.method === 'update' || options.method === 'patch') {
|
|
ops.push(function updateTags() {
|
|
return self.updateTags(model, attr, options);
|
|
});
|
|
}
|
|
|
|
// If a title is set, not the same as the old title, a draft post, and has never been published
|
|
if (prevTitle !== undefined && newTitle !== prevTitle && newStatus === 'draft' && !publishedAt) {
|
|
ops.push(function updateSlug() {
|
|
// Pass the new slug through the generator to strip illegal characters, detect duplicates
|
|
return ghostBookshelf.Model.generateSlug(Post, self.get('title'),
|
|
{status: 'all', transacting: options.transacting, importing: options.importing})
|
|
.then(function then(slug) {
|
|
// After the new slug is found, do another generate for the old title to compare it to the old slug
|
|
return ghostBookshelf.Model.generateSlug(Post, prevTitle,
|
|
{status: 'all', transacting: options.transacting, importing: options.importing}
|
|
).then(function then(prevTitleSlug) {
|
|
// If the old slug is the same as the slug that was generated from the old title
|
|
// then set a new slug. If it is not the same, means was set by the user
|
|
if (prevTitleSlug === prevSlug) {
|
|
self.set({slug: slug});
|
|
}
|
|
});
|
|
});
|
|
});
|
|
} else {
|
|
ops.push(function updateSlug() {
|
|
// If any of the attributes above were false, set initial slug and check to see if slug was changed by the user
|
|
if (self.hasChanged('slug') || !self.get('slug')) {
|
|
// Pass the new slug through the generator to strip illegal characters, detect duplicates
|
|
return ghostBookshelf.Model.generateSlug(Post, self.get('slug') || self.get('title'),
|
|
{status: 'all', transacting: options.transacting, importing: options.importing})
|
|
.then(function then(slug) {
|
|
self.set({slug: slug});
|
|
});
|
|
}
|
|
|
|
return Promise.resolve();
|
|
});
|
|
}
|
|
|
|
return sequence(ops);
|
|
},
|
|
|
|
onCreating: function onCreating(model, attr, options) {
|
|
options = options || {};
|
|
|
|
// set any dynamic default properties
|
|
if (!this.get('author_id')) {
|
|
this.set('author_id', this.contextUser(options));
|
|
}
|
|
|
|
ghostBookshelf.Model.prototype.onCreating.call(this, model, attr, options);
|
|
},
|
|
|
|
/**
|
|
* ### updateTags
|
|
* Update tags that are attached to a post. Create any tags that don't already exist.
|
|
* @param {Object} savedModel
|
|
* @param {Object} response
|
|
* @param {Object} options
|
|
* @return {Promise(ghostBookshelf.Models.Post)} Updated Post model
|
|
*/
|
|
updateTags: function updateTags(savedModel, response, options) {
|
|
if (_.isUndefined(this.tagsToSave)) {
|
|
// The tag property was not set, so we shouldn't be doing any playing with tags on this request
|
|
return Promise.resolve();
|
|
}
|
|
|
|
var newTags = this.tagsToSave,
|
|
TagModel = ghostBookshelf.model('Tag');
|
|
|
|
options = options || {};
|
|
|
|
function doTagUpdates(options) {
|
|
return Promise.props({
|
|
currentPost: baseUtils.tagUpdate.fetchCurrentPost(Post, savedModel.id, options),
|
|
existingTags: baseUtils.tagUpdate.fetchMatchingTags(TagModel, newTags, options)
|
|
}).then(function fetchedData(results) {
|
|
var currentTags = results.currentPost.related('tags').toJSON(options),
|
|
existingTags = results.existingTags ? results.existingTags.toJSON(options) : [],
|
|
tagOps = [],
|
|
tagsToRemove,
|
|
tagsToCreate;
|
|
|
|
// CASE: if nothing has changed, unset `tags`.
|
|
if (baseUtils.tagUpdate.tagSetsAreEqual(newTags, currentTags)) {
|
|
savedModel.unset('tags');
|
|
return;
|
|
}
|
|
|
|
// Tags from the current tag array which don't exist in the new tag array should be removed
|
|
tagsToRemove = _.reject(currentTags, function (currentTag) {
|
|
if (newTags.length === 0) {
|
|
return false;
|
|
}
|
|
return _.some(newTags, function (newTag) {
|
|
return baseUtils.tagUpdate.tagsAreEqual(currentTag, newTag);
|
|
});
|
|
});
|
|
|
|
// Tags from the new tag array which don't exist in the DB should be created
|
|
tagsToCreate = _.map(_.reject(newTags, function (newTag) {
|
|
return _.some(existingTags, function (existingTag) {
|
|
return baseUtils.tagUpdate.tagsAreEqual(existingTag, newTag);
|
|
});
|
|
}), 'name');
|
|
|
|
// Remove any tags which don't exist anymore
|
|
_.each(tagsToRemove, function (tag) {
|
|
tagOps.push(baseUtils.tagUpdate.detachTagFromPost(savedModel, tag, options));
|
|
});
|
|
|
|
// Loop through the new tags and either add them, attach them, or update them
|
|
_.each(newTags, function (newTag, index) {
|
|
var tag;
|
|
|
|
if (tagsToCreate.indexOf(newTag.name) > -1) {
|
|
tagOps.push(baseUtils.tagUpdate.createTagThenAttachTagToPost(Post, TagModel, savedModel, newTag, index, options));
|
|
} else {
|
|
// try to find a tag on the current post which matches
|
|
tag = _.find(currentTags, function (currentTag) {
|
|
return baseUtils.tagUpdate.tagsAreEqual(currentTag, newTag);
|
|
});
|
|
|
|
if (tag) {
|
|
tagOps.push(baseUtils.tagUpdate.updateTagOrderForPost(savedModel, tag, index, options));
|
|
return;
|
|
}
|
|
|
|
// else finally, find the existing tag which matches
|
|
tag = _.find(existingTags, function (existingTag) {
|
|
return baseUtils.tagUpdate.tagsAreEqual(existingTag, newTag);
|
|
});
|
|
|
|
if (tag) {
|
|
tagOps.push(baseUtils.tagUpdate.attachTagToPost(Post, savedModel.id, tag, index, options));
|
|
}
|
|
}
|
|
});
|
|
|
|
return sequence(tagOps);
|
|
});
|
|
}
|
|
|
|
// Handle updating tags in a transaction, unless we're already in one
|
|
if (options.transacting) {
|
|
return doTagUpdates(options);
|
|
} else {
|
|
return ghostBookshelf.transaction(function (t) {
|
|
options.transacting = t;
|
|
|
|
return doTagUpdates(options);
|
|
}).then(function () {
|
|
// Don't do anything, the transaction processed ok
|
|
}).catch(function failure(err) {
|
|
return Promise.reject(new errors.GhostError({
|
|
err: err,
|
|
context: i18n.t('errors.models.post.tagUpdates.error'),
|
|
help: i18n.t('errors.models.post.tagUpdates.help')
|
|
}));
|
|
});
|
|
}
|
|
},
|
|
|
|
// Relations
|
|
author: function author() {
|
|
return this.belongsTo('User', 'author_id');
|
|
},
|
|
|
|
created_by: function createdBy() {
|
|
return this.belongsTo('User', 'created_by');
|
|
},
|
|
|
|
updated_by: function updatedBy() {
|
|
return this.belongsTo('User', 'updated_by');
|
|
},
|
|
|
|
published_by: function publishedBy() {
|
|
return this.belongsTo('User', 'published_by');
|
|
},
|
|
|
|
tags: function tags() {
|
|
return this.belongsToMany('Tag').withPivot('sort_order').query('orderBy', 'sort_order', 'ASC');
|
|
},
|
|
|
|
fields: function fields() {
|
|
return this.morphMany('AppField', 'relatable');
|
|
},
|
|
|
|
defaultColumnsToFetch: function defaultColumnsToFetch() {
|
|
return ['id', 'published_at', 'slug', 'author_id'];
|
|
},
|
|
|
|
toJSON: function toJSON(options) {
|
|
options = options || {};
|
|
|
|
var attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options);
|
|
|
|
if (!options.columns || (options.columns && options.columns.indexOf('author') > -1)) {
|
|
attrs.author = attrs.author || attrs.author_id;
|
|
delete attrs.author_id;
|
|
}
|
|
|
|
if (!options.columns || (options.columns && options.columns.indexOf('url') > -1)) {
|
|
attrs.url = utils.url.urlPathForPost(attrs);
|
|
}
|
|
|
|
return attrs;
|
|
},
|
|
enforcedFilters: function enforcedFilters() {
|
|
return this.isPublicContext() ? 'status:published' : null;
|
|
},
|
|
defaultFilters: function defaultFilters() {
|
|
if (this.isInternalContext()) {
|
|
return null;
|
|
}
|
|
|
|
return this.isPublicContext() ? 'page:false' : 'page:false+status:published';
|
|
}
|
|
}, {
|
|
orderDefaultOptions: function orderDefaultOptions() {
|
|
return {
|
|
status: 'ASC',
|
|
published_at: 'DESC',
|
|
updated_at: 'DESC',
|
|
id: 'DESC'
|
|
};
|
|
},
|
|
|
|
orderDefaultRaw: function () {
|
|
return '' +
|
|
'CASE WHEN posts.status = \'scheduled\' THEN 1 ' +
|
|
'WHEN posts.status = \'draft\' THEN 2 ' +
|
|
'ELSE 3 END ASC,' +
|
|
'posts.published_at DESC,' +
|
|
'posts.updated_at DESC,' +
|
|
'posts.id DESC';
|
|
},
|
|
|
|
/**
|
|
* @deprecated in favour of filter
|
|
*/
|
|
processOptions: function processOptions(options) {
|
|
if (!options.staticPages && !options.status) {
|
|
return options;
|
|
}
|
|
|
|
// This is the only place that 'options.where' is set now
|
|
options.where = {statements: []};
|
|
|
|
// Step 4: Setup filters (where clauses)
|
|
if (options.staticPages && options.staticPages !== 'all') {
|
|
// convert string true/false to boolean
|
|
if (!_.isBoolean(options.staticPages)) {
|
|
options.staticPages = _.includes(['true', '1'], options.staticPages);
|
|
}
|
|
options.where.statements.push({prop: 'page', op: '=', value: options.staticPages});
|
|
delete options.staticPages;
|
|
} else if (options.staticPages === 'all') {
|
|
options.where.statements.push({prop: 'page', op: 'IN', value: [true, false]});
|
|
delete options.staticPages;
|
|
}
|
|
|
|
// Unless `all` is passed as an option, filter on
|
|
// the status provided.
|
|
if (options.status && options.status !== 'all') {
|
|
// make sure that status is valid
|
|
options.status = _.includes(['published', 'draft', 'scheduled'], options.status) ? options.status : 'published';
|
|
options.where.statements.push({prop: 'status', op: '=', value: options.status});
|
|
delete options.status;
|
|
} else if (options.status === 'all') {
|
|
options.where.statements.push({prop: 'status', op: 'IN', value: ['published', 'draft', 'scheduled']});
|
|
delete options.status;
|
|
}
|
|
|
|
return options;
|
|
},
|
|
|
|
/**
|
|
* Returns an array of keys permitted in a method's `options` hash, depending on the current method.
|
|
* @param {String} methodName The name of the method to check valid options for.
|
|
* @return {Array} Keys allowed in the `options` hash of the model's method.
|
|
*/
|
|
permittedOptions: function permittedOptions(methodName) {
|
|
var options = ghostBookshelf.Model.permittedOptions(),
|
|
|
|
// whitelists for the `options` hash argument on methods, by method name.
|
|
// these are the only options that can be passed to Bookshelf / Knex.
|
|
validOptions = {
|
|
findOne: ['columns', 'importing', 'withRelated', 'require', 'forUpdate'],
|
|
findPage: ['page', 'limit', 'columns', 'filter', 'order', 'status', 'staticPages'],
|
|
findAll: ['columns', 'filter', 'forUpdate'],
|
|
edit: ['forUpdate']
|
|
};
|
|
|
|
if (validOptions[methodName]) {
|
|
options = options.concat(validOptions[methodName]);
|
|
}
|
|
|
|
return options;
|
|
},
|
|
|
|
/**
|
|
* Manually add 'tags' attribute since it's not in the schema and call parent.
|
|
*
|
|
* @param {Object} data Has keys representing the model's attributes/fields in the database.
|
|
* @return {Object} The filtered results of the passed in data, containing only what's allowed in the schema.
|
|
*/
|
|
filterData: function filterData(data) {
|
|
var filteredData = ghostBookshelf.Model.filterData.apply(this, arguments),
|
|
extraData = _.pick(data, ['tags']);
|
|
|
|
_.merge(filteredData, extraData);
|
|
return filteredData;
|
|
},
|
|
|
|
// ## Model Data Functions
|
|
|
|
/**
|
|
* ### Find One
|
|
* @extends ghostBookshelf.Model.findOne to handle post status
|
|
* **See:** [ghostBookshelf.Model.findOne](base.js.html#Find%20One)
|
|
*/
|
|
findOne: function findOne(data, options) {
|
|
options = options || {};
|
|
|
|
var withNext = _.includes(options.include, 'next'),
|
|
withPrev = _.includes(options.include, 'previous'),
|
|
nextRelations = _.transform(options.include, function (relations, include) {
|
|
if (include === 'next.tags') {
|
|
relations.push('tags');
|
|
} else if (include === 'next.author') {
|
|
relations.push('author');
|
|
}
|
|
}, []),
|
|
prevRelations = _.transform(options.include, function (relations, include) {
|
|
if (include === 'previous.tags') {
|
|
relations.push('tags');
|
|
} else if (include === 'previous.author') {
|
|
relations.push('author');
|
|
}
|
|
}, []);
|
|
|
|
data = _.defaults(data || {}, {
|
|
status: 'published'
|
|
});
|
|
|
|
if (data.status === 'all') {
|
|
delete data.status;
|
|
}
|
|
|
|
// Add related objects, excluding next and previous as they are not real db objects
|
|
options.withRelated = _.union(options.withRelated, _.pull(
|
|
[].concat(options.include),
|
|
'next', 'next.author', 'next.tags', 'previous', 'previous.author', 'previous.tags')
|
|
);
|
|
|
|
return ghostBookshelf.Model.findOne.call(this, data, options).then(function then(post) {
|
|
if ((withNext || withPrev) && post && !post.page) {
|
|
var publishedAt = moment(post.get('published_at')).format('YYYY-MM-DD HH:mm:ss'),
|
|
prev,
|
|
next;
|
|
|
|
if (withNext) {
|
|
next = Post.forge().query(function queryBuilder(qb) {
|
|
qb.where('status', '=', 'published')
|
|
.andWhere('page', '=', 0)
|
|
.andWhere('published_at', '>', publishedAt)
|
|
.orderBy('published_at', 'asc')
|
|
.limit(1);
|
|
}).fetch({withRelated: nextRelations});
|
|
}
|
|
|
|
if (withPrev) {
|
|
prev = Post.forge().query(function queryBuilder(qb) {
|
|
qb.where('status', '=', 'published')
|
|
.andWhere('page', '=', 0)
|
|
.andWhere('published_at', '<', publishedAt)
|
|
.orderBy('published_at', 'desc')
|
|
.limit(1);
|
|
}).fetch({withRelated: prevRelations});
|
|
}
|
|
|
|
return Promise.join(next, prev)
|
|
.then(function then(nextAndPrev) {
|
|
if (nextAndPrev[0]) {
|
|
post.relations.next = nextAndPrev[0];
|
|
}
|
|
if (nextAndPrev[1]) {
|
|
post.relations.previous = nextAndPrev[1];
|
|
}
|
|
return post;
|
|
});
|
|
}
|
|
|
|
return post;
|
|
});
|
|
},
|
|
|
|
/**
|
|
* ### Edit
|
|
* Fetches and saves to Post. See model.Base.edit
|
|
*
|
|
* @extends ghostBookshelf.Model.edit to handle returning the full object and manage _updatedAttributes
|
|
* **See:** [ghostBookshelf.Model.edit](base.js.html#edit)
|
|
*/
|
|
edit: function edit(data, options) {
|
|
var self = this,
|
|
editPost = function editPost(data, options) {
|
|
options.forUpdate = true;
|
|
|
|
return ghostBookshelf.Model.edit.call(self, data, options).then(function then(post) {
|
|
return self.findOne({status: 'all', id: options.id}, options)
|
|
.then(function then(found) {
|
|
if (found) {
|
|
// Pass along the updated attributes for checking status changes
|
|
found._updatedAttributes = post._updatedAttributes;
|
|
return found;
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
options = options || {};
|
|
|
|
if (options.transacting) {
|
|
return editPost(data, options);
|
|
}
|
|
|
|
return ghostBookshelf.transaction(function (transacting) {
|
|
options.transacting = transacting;
|
|
return editPost(data, options);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* ### Add
|
|
* @extends ghostBookshelf.Model.add to handle returning the full object
|
|
* **See:** [ghostBookshelf.Model.add](base.js.html#add)
|
|
*/
|
|
add: function add(data, options) {
|
|
var self = this;
|
|
options = options || {};
|
|
|
|
return ghostBookshelf.Model.add.call(this, data, options).then(function then(post) {
|
|
return self.findOne({status: 'all', id: post.id}, options);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* ### destroyByAuthor
|
|
* @param {[type]} options has context and id. Context is the user doing the destroy, id is the user to destroy
|
|
*/
|
|
destroyByAuthor: Promise.method(function destroyByAuthor(options) {
|
|
var postCollection = Posts.forge(),
|
|
authorId = options.id;
|
|
|
|
options = this.filterOptions(options, 'destroyByAuthor');
|
|
|
|
if (!authorId) {
|
|
throw new errors.NotFoundError({message: i18n.t('errors.models.post.noUserFound')});
|
|
}
|
|
|
|
return postCollection
|
|
.query('where', 'author_id', '=', authorId)
|
|
.fetch(options)
|
|
.call('invokeThen', 'destroy', options)
|
|
.catch(function (err) {
|
|
return Promise.reject(new errors.GhostError({err: err}));
|
|
});
|
|
}),
|
|
|
|
permissible: function permissible(postModelOrId, action, context, loadedPermissions, hasUserPermission, hasAppPermission) {
|
|
var self = this,
|
|
postModel = postModelOrId,
|
|
origArgs;
|
|
|
|
// If we passed in an id instead of a model, get the model
|
|
// then check the permissions
|
|
if (_.isNumber(postModelOrId) || _.isString(postModelOrId)) {
|
|
// Grab the original args without the first one
|
|
origArgs = _.toArray(arguments).slice(1);
|
|
|
|
// Get the actual post model
|
|
return this.findOne({id: postModelOrId, status: 'all'}).then(function then(foundPostModel) {
|
|
// Build up the original args but substitute with actual model
|
|
var newArgs = [foundPostModel].concat(origArgs);
|
|
|
|
return self.permissible.apply(self, newArgs);
|
|
});
|
|
}
|
|
|
|
if (postModel) {
|
|
// If this is the author of the post, allow it.
|
|
hasUserPermission = hasUserPermission || context.user === postModel.get('author_id');
|
|
}
|
|
|
|
if (hasUserPermission && hasAppPermission) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return Promise.reject(new errors.NoPermissionError({message: i18n.t('errors.models.post.notEnoughPermission')}));
|
|
}
|
|
});
|
|
|
|
Posts = ghostBookshelf.Collection.extend({
|
|
model: Post
|
|
});
|
|
|
|
module.exports = {
|
|
Post: ghostBookshelf.model('Post', Post),
|
|
Posts: ghostBookshelf.collection('Posts', Posts)
|
|
};
|