/
base.js
369 lines (313 loc) · 12.7 KB
/
base.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
// # Base Model
// This is the model from which all other Ghost models extend. The model is based on Bookshelf.Model, and provides
// several basic behaviours such as UUIDs, as well as a set of Data methods for accessing information from the database.
//
// The models are internal to Ghost, only the API and some internal functions such as migration and import/export
// accesses the models directly. All other parts of Ghost, including the blog frontend, admin UI, and apps are only
// allowed to access data via the API.
var bookshelf = require('bookshelf'),
when = require('when'),
moment = require('moment'),
_ = require('lodash'),
uuid = require('node-uuid'),
config = require('../config'),
utils = require('../utils'),
sanitize = require('validator').sanitize,
schema = require('../data/schema'),
validation = require('../data/validation'),
errors = require('../errors'),
ghostBookshelf;
// ### ghostBookshelf
// Initializes a new Bookshelf instance called ghostBookshelf, for reference elsewhere in Ghost.
ghostBookshelf = bookshelf(config.database.knex);
// Load the registry plugin, which helps us avoid circular dependencies
ghostBookshelf.plugin('registry');
// ### ghostBookshelf.Model
// The Base Model which other Ghost objects will inherit from,
// including some convenience functions as static properties on the model.
ghostBookshelf.Model = ghostBookshelf.Model.extend({
hasTimestamps: true,
// Get permitted attributes from server/data/schema.js, which is where the DB schema is defined
permittedAttributes: function () {
return _.keys(schema.tables[this.tableName]);
},
defaults: function () {
return {
uuid: uuid.v4()
};
},
initialize: function () {
var self = this,
options = arguments[1] || {};
// make options include available for toJSON()
if (options.include) {
this.include = _.clone(options.include);
}
this.on('creating', this.creating, this);
this.on('saving', function (model, attributes, options) {
return when(self.saving(model, attributes, options)).then(function () {
return self.validate(model, attributes, options);
});
});
},
validate: function () {
return validation.validateSchema(this.tableName, this.toJSON());
},
creating: function (newObj, attr, options) {
if (!this.get('created_by')) {
this.set('created_by', this.contextUser(options));
}
},
saving: function (newObj, attr, options) {
// Remove any properties which don't belong on the model
this.attributes = this.pick(this.permittedAttributes());
// Store the previous attributes so we can tell what was updated later
this._updatedAttributes = newObj.previousAttributes();
this.set('updated_by', this.contextUser(options));
},
// Base prototype properties will go here
// Fix problems with dates
fixDates: function (attrs) {
var self = this;
_.each(attrs, function (value, key) {
if (value !== null
&& schema.tables[self.tableName].hasOwnProperty(key)
&& schema.tables[self.tableName][key].type === 'dateTime') {
// convert dateTime value into a native javascript Date object
attrs[key] = moment(value).toDate();
}
});
return attrs;
},
// Convert integers to real booleans
fixBools: function (attrs) {
var self = this;
_.each(attrs, function (value, key) {
if (schema.tables[self.tableName].hasOwnProperty(key)
&& schema.tables[self.tableName][key].type === 'bool') {
attrs[key] = value ? true : false;
}
});
return attrs;
},
// Get the user from the options object
contextUser: function (options) {
// Default to context user
if (options.context && options.context.user) {
return options.context.user;
// Other wise use the internal override
} else if (options.context && options.context.internal) {
return 1;
} else {
errors.logAndThrowError(new Error('missing context'));
}
},
// format date before writing to DB, bools work
format: function (attrs) {
return this.fixDates(attrs);
},
// format data and bool when fetching from DB
parse: function (attrs) {
return this.fixBools(this.fixDates(attrs));
},
toJSON: function (options) {
var attrs = _.extend({}, this.attributes),
self = this;
options = options || {};
if (options && options.shallow) {
return attrs;
}
if (options && options.idOnly) {
return attrs.id;
}
if (options && options.include) {
this.include = _.union(this.include, options.include);
}
_.each(this.relations, function (relation, key) {
if (key.substring(0, 7) !== '_pivot_') {
// if include is set, expand to full object
// 'toMany' relationships are included with ids if not expanded
var fullKey = _.isEmpty(options.name) ? key : options.name + '.' + key;
if (_.contains(self.include, fullKey)) {
attrs[key] = relation.toJSON({name: fullKey, include: self.include});
} else if (relation.hasOwnProperty('length')) {
attrs[key] = relation.toJSON({idOnly: true});
}
}
});
return attrs;
},
sanitize: function (attr) {
return sanitize(this.get(attr)).xss();
},
// Get attributes that have been updated (values before a .save() call)
updatedAttributes: function () {
return this._updatedAttributes || {};
},
// Get a specific updated attribute value
updated: function (attr) {
return this.updatedAttributes()[attr];
}
}, {
// ## Data Utility Functions
/**
* Returns an array of keys permitted in every method's `options` hash.
* Can be overridden and added to by a model's `permittedOptions` method.
* @return {Array} Keys allowed in the `options` hash of every model's method.
*/
permittedOptions: function () {
// terms to whitelist for all methods.
return ['context', 'include', 'transacting'];
},
/**
* 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 (data) {
var permittedAttributes = this.prototype.permittedAttributes(),
filteredData = _.pick(data, permittedAttributes);
return filteredData;
},
/**
* Filters potentially unsafe `options` in a model method's arguments, so you can pass them to Bookshelf / Knex.
* @param {Object} options Represents options to filter in order to be passed to the Bookshelf query.
* @param {String} methodName The name of the method to check valid options for.
* @return {Object} The filtered results of `options`.
*/
filterOptions: function (options, methodName) {
var permittedOptions = this.permittedOptions(methodName),
filteredOptions = _.pick(options, permittedOptions);
return filteredOptions;
},
// ## Model Data Functions
/**
* ### Find All
* Naive find all fetches all the data for a particular model
* @param {Object} options (optional)
* @return {Promise(ghostBookshelf.Collection)} Collection of all Models
*/
findAll: function (options) {
options = this.filterOptions(options, 'findAll');
return ghostBookshelf.Collection.forge([], {model: this}).fetch(options).then(function (result) {
if (options.include) {
_.each(result.models, function (item) {
item.include = options.include;
});
}
return result;
});
},
/**
* ### Find One
* Naive find one where data determines what to match on
* @param {Object} data
* @param {Object} options (optional)
* @return {Promise(ghostBookshelf.Model)} Single Model
*/
findOne: function (data, options) {
data = this.filterData(data);
options = this.filterOptions(options, 'findOne');
// We pass include to forge so that toJSON has access
return this.forge(data, {include: options.include}).fetch(options);
},
/**
* ### Edit
* Naive edit
* @param {Object} data
* @param {Object} options (optional)
* @return {Promise(ghostBookshelf.Model)} Edited Model
*/
edit: function (data, options) {
var id = options.id;
data = this.filterData(data);
options = this.filterOptions(options, 'edit');
return this.forge({id: id}).fetch(options).then(function (object) {
if (object) {
return object.save(data, options);
}
});
},
/**
* ### Add
* Naive add
* @param {Object} data
* @param {Object} options (optional)
* @return {Promise(ghostBookshelf.Model)} Newly Added Model
*/
add: function (data, options) {
data = this.filterData(data);
options = this.filterOptions(options, 'add');
var instance = this.forge(data);
// We allow you to disable timestamps when importing posts so that the new posts `updated_at` value is the same
// as the import json blob. More details refer to https://github.com/TryGhost/Ghost/issues/1696
if (options.importing) {
instance.hasTimestamps = false;
}
return instance.save(null, options);
},
/**
* ### Destroy
* Naive destroy
* @param {Object} options (optional)
* @return {Promise(ghostBookshelf.Model)} Empty Model
*/
destroy: function (options) {
var id = options.id;
options = this.filterOptions(options, 'destroy');
return this.forge({id: id}).destroy(options);
},
/**
* ### Generate Slug
* Create a string to act as the permalink for an object.
* @param {ghostBookshelf.Model} Model Model type to generate a slug for
* @param {String} base The string for which to generate a slug, usually a title or name
* @param {Object} options Options to pass to findOne
* @return {Promise(String)} Resolves to a unique slug string
*/
generateSlug: function (Model, base, options) {
var slug,
slugTryCount = 1,
baseName = Model.prototype.tableName.replace(/s$/, ''),
// Look for a matching slug, append an incrementing number if so
checkIfSlugExists;
checkIfSlugExists = function (slugToFind) {
var args = {slug: slugToFind};
//status is needed for posts
if (options && options.status) {
args.status = options.status;
}
return Model.findOne(args, options).then(function (found) {
var trimSpace;
if (!found) {
return when.resolve(slugToFind);
}
slugTryCount += 1;
// If this is the first time through, add the hyphen
if (slugTryCount === 2) {
slugToFind += '-';
} else {
// Otherwise, trim the number off the end
trimSpace = -(String(slugTryCount - 1).length);
slugToFind = slugToFind.slice(0, trimSpace);
}
slugToFind += slugTryCount;
return checkIfSlugExists(slugToFind);
});
};
slug = utils.safeString(base);
// Remove trailing hyphen
slug = slug.charAt(slug.length - 1) === '-' ? slug.substr(0, slug.length - 1) : slug;
// Check the filtered slug doesn't match any of the reserved keywords
slug = /^(ghost|ghost\-admin|admin|wp\-admin|wp\-login|dashboard|logout|login|setup|signin|signup|signout|register|archive|archives|category|categories|tag|tags|page|pages|post|posts|public|user|users|rss|feed|app|apps)$/g
.test(slug) ? slug + '-' + baseName : slug;
//if slug is empty after trimming use the model name
if (!slug) {
slug = baseName;
}
// Test for duplicate slugs.
return checkIfSlugExists(slug);
}
});
// Export ghostBookshelf for use elsewhere
module.exports = ghostBookshelf;