mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-18 07:51:55 +03:00
81c215dbe5
Closes #5062 * As long as the post has never been published, update the slug to match the new title if the title is changed
626 lines
24 KiB
JavaScript
626 lines
24 KiB
JavaScript
// # Post Model
|
|
var _ = require('lodash'),
|
|
uuid = require('node-uuid'),
|
|
Promise = require('bluebird'),
|
|
sequence = require('../utils/sequence'),
|
|
errors = require('../errors'),
|
|
Showdown = require('showdown-ghost'),
|
|
converter = new Showdown.converter({extensions: ['ghostgfm', 'footnotes', 'highlight']}),
|
|
ghostBookshelf = require('./base'),
|
|
events = require('../events'),
|
|
config = require('../config'),
|
|
baseUtils = require('./base/utils'),
|
|
i18n = require('../i18n'),
|
|
Post,
|
|
Posts;
|
|
|
|
Post = ghostBookshelf.Model.extend({
|
|
|
|
tableName: 'posts',
|
|
|
|
emitChange: function emitChange(event, usePreviousResourceType) {
|
|
var resourceType = this.get('page') ? 'page' : 'post';
|
|
if (usePreviousResourceType) {
|
|
resourceType = this.updated('page') ? 'page' : 'post';
|
|
}
|
|
events.emit(resourceType + '.' + event, this);
|
|
},
|
|
|
|
defaults: function defaults() {
|
|
return {
|
|
uuid: uuid.v4(),
|
|
status: 'draft'
|
|
};
|
|
},
|
|
|
|
initialize: function initialize() {
|
|
var self = this;
|
|
|
|
ghostBookshelf.Model.prototype.initialize.apply(this, arguments);
|
|
|
|
this.on('saved', function onSaved(model, response, options) {
|
|
return self.updateTags(model, response, options);
|
|
});
|
|
|
|
this.on('created', function onCreated(model) {
|
|
model.emitChange('added');
|
|
|
|
if (model.get('status') === 'published') {
|
|
model.emitChange('published');
|
|
}
|
|
});
|
|
|
|
this.on('updated', function onUpdated(model) {
|
|
model.statusChanging = model.get('status') !== model.updated('status');
|
|
model.isPublished = model.get('status') === 'published';
|
|
model.wasPublished = model.updated('status') === 'published';
|
|
model.resourceTypeChanging = model.get('page') !== model.updated('page');
|
|
|
|
// Handle added and deleted for changing resource
|
|
if (model.resourceTypeChanging) {
|
|
if (model.wasPublished) {
|
|
model.emitChange('unpublished', true);
|
|
}
|
|
|
|
model.emitChange('deleted', true);
|
|
model.emitChange('added');
|
|
|
|
if (model.isPublished) {
|
|
model.emitChange('published');
|
|
}
|
|
} else {
|
|
if (model.statusChanging) {
|
|
model.emitChange(model.isPublished ? 'published' : 'unpublished');
|
|
} else {
|
|
if (model.isPublished) {
|
|
model.emitChange('published.edited');
|
|
}
|
|
}
|
|
|
|
// Fire edited if this wasn't a change between resourceType
|
|
model.emitChange('edited');
|
|
}
|
|
});
|
|
|
|
this.on('destroying', function onDestroying(model) {
|
|
if (model.previous('status') === 'published') {
|
|
model.emitChange('unpublished');
|
|
}
|
|
model.emitChange('deleted');
|
|
});
|
|
},
|
|
|
|
saving: function saving(model, attr, options) {
|
|
var self = this,
|
|
tagsToCheck,
|
|
title,
|
|
i,
|
|
// Variables to make the slug checking more readable
|
|
newTitle = this.get('title'),
|
|
prevTitle = this._previousAttributes.title,
|
|
prevSlug = this._previousAttributes.slug,
|
|
postStatus = this.get('status'),
|
|
publishedAt = this.get('published_at');
|
|
|
|
options = options || {};
|
|
// keep tags for 'saved' event and deduplicate upper/lowercase tags
|
|
tagsToCheck = this.get('tags');
|
|
this.myTags = [];
|
|
|
|
_.each(tagsToCheck, function each(item) {
|
|
for (i = 0; i < self.myTags.length; i = i + 1) {
|
|
if (self.myTags[i].name.toLocaleLowerCase() === item.name.toLocaleLowerCase()) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
self.myTags.push(item);
|
|
});
|
|
|
|
ghostBookshelf.Model.prototype.saving.call(this, model, attr, options);
|
|
|
|
this.set('html', converter.makeHtml(this.get('markdown')));
|
|
|
|
// disabling sanitization until we can implement a better version
|
|
// this.set('title', this.sanitize('title').trim());
|
|
title = this.get('title') || i18n.t('errors.models.post.untitled');
|
|
this.set('title', 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 (this.get('status') === 'published' && !this.get('published_at')) {
|
|
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 (this.get('status') === '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'));
|
|
}
|
|
}
|
|
|
|
// 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 && postStatus === 'draft' && !publishedAt) {
|
|
// Pass the new slug through the generator to strip illegal characters, detect duplicates
|
|
return ghostBookshelf.Model.generateSlug(Post, this.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).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 {
|
|
// If any of the attributes above were false, set initial slug and check to see if slug was changed by the user
|
|
if (this.hasChanged('slug') || !this.get('slug')) {
|
|
// Pass the new slug through the generator to strip illegal characters, detect duplicates
|
|
return ghostBookshelf.Model.generateSlug(Post, this.get('slug') || this.get('title'),
|
|
{status: 'all', transacting: options.transacting, importing: options.importing})
|
|
.then(function then(slug) {
|
|
self.set({slug: slug});
|
|
});
|
|
}
|
|
}
|
|
},
|
|
|
|
creating: function creating(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.creating.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) {
|
|
var newTags = this.myTags,
|
|
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;
|
|
|
|
if (baseUtils.tagUpdate.tagSetsAreEqual(newTags, currentTags)) {
|
|
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 _.any(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 = _.pluck(_.reject(newTags, function (newTag) {
|
|
return _.any(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(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(savedModel, 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(error) {
|
|
errors.logError(
|
|
error,
|
|
i18n.t('errors.models.post.tagUpdates.error'),
|
|
i18n.t('errors.models.post.tagUpdates.help')
|
|
);
|
|
return Promise.reject(new errors.InternalServerError(
|
|
i18n.t('errors.models.post.tagUpdates.error') + ' ' + i18n.t('errors.models.post.tagUpdates.help') + error
|
|
));
|
|
});
|
|
}
|
|
},
|
|
|
|
// 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');
|
|
},
|
|
|
|
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 = config.urlPathForPost(attrs);
|
|
}
|
|
|
|
return attrs;
|
|
},
|
|
enforcedFilters: function enforcedFilters() {
|
|
return this.isPublicContext() ? 'status:published' : null;
|
|
},
|
|
defaultFilters: function defaultFilters() {
|
|
return this.isPublicContext() ? 'page:false' : 'page:false+status:published';
|
|
}
|
|
}, {
|
|
orderDefaultOptions: function orderDefaultOptions() {
|
|
return {
|
|
status: 'ASC',
|
|
published_at: 'DESC',
|
|
updated_at: 'DESC',
|
|
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 = _.contains(['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 = _.contains(['published', 'draft'], 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']});
|
|
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: ['importing', 'withRelated'],
|
|
findPage: ['page', 'limit', 'columns', 'filter', 'order', 'status', 'staticPages'],
|
|
add: ['importing']
|
|
};
|
|
|
|
if (validOptions[methodName]) {
|
|
options = options.concat(validOptions[methodName]);
|
|
}
|
|
|
|
return options;
|
|
},
|
|
|
|
/**
|
|
* Filters potentially unsafe model attributes, so you can pass them to Bookshelf / Knex.
|
|
* @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 permittedAttributes = this.prototype.permittedAttributes(),
|
|
filteredData;
|
|
|
|
// manually add 'tags' attribute since it's not in the schema
|
|
permittedAttributes.push('tags');
|
|
|
|
filteredData = _.pick(data, permittedAttributes);
|
|
|
|
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 = _.contains(options.include, 'next'),
|
|
withPrev = _.contains(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 = post.get('published_at'),
|
|
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
|
|
* @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;
|
|
options = options || {};
|
|
|
|
return ghostBookshelf.Model.edit.call(this, 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;
|
|
}
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* ### 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);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* ### Destroy
|
|
* @extends ghostBookshelf.Model.destroy to clean up tag relations
|
|
* **See:** [ghostBookshelf.Model.destroy](base.js.html#destroy)
|
|
*/
|
|
destroy: function destroy(options) {
|
|
var id = options.id;
|
|
options = this.filterOptions(options, 'destroy');
|
|
|
|
return this.forge({id: id}).fetch({withRelated: ['tags']}).then(function destroyTags(post) {
|
|
return post.related('tags').detach().then(function destroyPosts() {
|
|
return post.destroy(options);
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* ### destroyByAuthor
|
|
* @param {[type]} options has context and id. Context is the user doing the destroy, id is the user to destroy
|
|
*/
|
|
destroyByAuthor: function destroyByAuthor(options) {
|
|
var postCollection = Posts.forge(),
|
|
authorId = options.id;
|
|
|
|
options = this.filterOptions(options, 'destroyByAuthor');
|
|
if (authorId) {
|
|
return postCollection.query('where', 'author_id', '=', authorId).fetch(options).then(function destroyTags(results) {
|
|
return Promise.map(results.models, function mapper(post) {
|
|
return post.related('tags').detach(null, options).then(function destroyPosts() {
|
|
return post.destroy(options);
|
|
});
|
|
});
|
|
}, function (error) {
|
|
return Promise.reject(new errors.InternalServerError(error.message || error));
|
|
});
|
|
}
|
|
return Promise.reject(new errors.NotFoundError(i18n.t('errors.models.post.noUserFound')));
|
|
},
|
|
|
|
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);
|
|
}, errors.logAndThrowError);
|
|
}
|
|
|
|
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(i18n.t('errors.models.post.notEnoughPermission')));
|
|
}
|
|
});
|
|
|
|
Posts = ghostBookshelf.Collection.extend({
|
|
model: Post
|
|
});
|
|
|
|
module.exports = {
|
|
Post: ghostBookshelf.model('Post', Post),
|
|
Posts: ghostBookshelf.collection('Posts', Posts)
|
|
};
|