diff --git a/core/server/models/base/index.js b/core/server/models/base/index.js index 6179f10eb6..40d5ceb7d9 100644 --- a/core/server/models/base/index.js +++ b/core/server/models/base/index.js @@ -22,7 +22,7 @@ const security = require('@tryghost/security'); const schema = require('../../data/schema'); const urlUtils = require('../../../shared/url-utils'); const bulkOperations = require('./bulk-operations'); -const plugins = require('../plugins'); +const plugins = require('@tryghost/bookshelf-plugins'); const tpl = require('@tryghost/tpl'); const messages = { @@ -1307,7 +1307,7 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({ nql(filter).querySQL(query); if (shouldHavePosts) { - require('../plugins/has-posts').addHasPostsWhere(tableNames[modelName], shouldHavePosts)(query); + plugins.hasPosts.addHasPostsWhere(tableNames[modelName], shouldHavePosts)(query); } if (options.id) { diff --git a/core/server/models/plugins/collision.js b/core/server/models/plugins/collision.js deleted file mode 100644 index e846caf57d..0000000000 --- a/core/server/models/plugins/collision.js +++ /dev/null @@ -1,95 +0,0 @@ -const moment = require('moment-timezone'); -const _ = require('lodash'); -const errors = require('@tryghost/errors'); - -/** - * @param {import('bookshelf')} Bookshelf - */ -module.exports = function (Bookshelf) { - const ParentModel = Bookshelf.Model; - - const Model = Bookshelf.Model.extend({ - /** - * Update collision protection. - * - * IMPORTANT NOTES: - * The `sync` method is called for any query e.g. update, add, delete, fetch - * - * We had the option to override Bookshelf's `save` method, but hooking into the `sync` method gives us - * the ability to access the `changed` object. Bookshelf already knows which attributes has changed. - * - * Bookshelf's timestamp function can't be overridden, as it's synchronous, there is no way to return an Error. - * - * If we want to enable the collision plugin for other tables, the queries might need to run in a transaction. - * This depends on if we fetch the model before editing. Imagine two concurrent requests come in, both would fetch - * the same current database values and both would succeed to update and override each other. - */ - sync: function timestamp(options) { - const parentSync = ParentModel.prototype.sync.apply(this, arguments); - const originalUpdateSync = parentSync.update; - const self = this; - - // CASE: only enabled for posts table - if (this.tableName !== 'posts' || - !self.serverData || - ((options.method !== 'update' && options.method !== 'patch') || !options.method) - ) { - return parentSync; - } - - /** - * Only hook into the update sync - * - * IMPORTANT NOTES: - * Even if the client sends a different `id` property, it get's ignored by bookshelf. - * Because you can't change the `id` of an existing post. - * - * HTML is always auto generated, ignore. - */ - parentSync.update = async function update() { - const response = await originalUpdateSync.apply(this, arguments); - const changed = _.omit(self._changed, [ - 'created_at', 'updated_at', 'author_id', 'id', - 'published_by', 'updated_by', 'html', 'plaintext' - ]); - - const clientUpdatedAt = moment(self.clientData.updated_at || self.serverData.updated_at || new Date()); - const serverUpdatedAt = moment(self.serverData.updated_at || clientUpdatedAt); - - if (Object.keys(changed).length) { - if (clientUpdatedAt.diff(serverUpdatedAt) !== 0) { - // @NOTE: This will rollback the update. We cannot know if relations were updated before doing the update. - throw new errors.UpdateCollisionError({ - message: 'Saving failed! Someone else is editing this post.', - code: 'UPDATE_COLLISION', - level: 'critical', - errorDetails: { - clientUpdatedAt: self.clientData.updated_at, - serverUpdatedAt: self.serverData.updated_at - } - }); - } - } - - return response; - }; - - return parentSync; - }, - - /** - * We have to remember current server data and client data. - * The `sync` method has no access to it. - * `updated_at` is already set to "Date.now" when the overridden `sync.update` is called. - * See https://github.com/tgriesser/bookshelf/blob/79c526870e618748caf94e7476a0bc796ee090a6/src/model.js#L955 - */ - save: function save(data) { - this.clientData = _.cloneDeep(data) || {}; - this.serverData = _.cloneDeep(this.attributes); - - return ParentModel.prototype.save.apply(this, arguments); - } - }); - - Bookshelf.Model = Model; -}; diff --git a/core/server/models/plugins/custom-query.js b/core/server/models/plugins/custom-query.js deleted file mode 100644 index ec373b8618..0000000000 --- a/core/server/models/plugins/custom-query.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @param {import('bookshelf')} Bookshelf - */ -const customQueryPlug = function customQueryPlug(Bookshelf) { - const Model = Bookshelf.Model.extend({ - // override this on the model itself - customQuery() {}, - - applyCustomQuery: function applyCustomQuery(options) { - this.query((qb) => { - this.customQuery(qb, options); - }); - } - }); - - Bookshelf.Model = Model; -}; - -module.exports = customQueryPlug; diff --git a/core/server/models/plugins/eager-load.js b/core/server/models/plugins/eager-load.js deleted file mode 100644 index 6ee15e8061..0000000000 --- a/core/server/models/plugins/eager-load.js +++ /dev/null @@ -1,82 +0,0 @@ -const _ = require('lodash'); -const _debug = require('ghost-ignition').debug._base; -const debug = _debug('ghost-query'); - -/** - * Enchances knex query builder with a join to relation configured in - * - * @param {Bookshelf['Model']} model instance of Bookshelf model - * @param {String[]} relationsToLoad relations to be included in joins - */ -function withEager(model, relationsToLoad) { - const tableName = _.result(model.constructor.prototype, 'tableName'); - - return function (qb) { - if (!model.relationsMeta) { - return qb; - } - - for (const [key, config] of Object.entries(model.relationsMeta)) { - if (relationsToLoad.includes(key)) { - const innerQb = qb - .leftJoin(config.targetTableName, `${tableName}.id`, `${config.targetTableName}.${config.foreignKey}`); - - debug(`QUERY has posts: ${innerQb.toSQL().sql}`); - } - } - - return qb; - }; -} - -function load(options) { - if (!options) { - return; - } - - if (this.eagerLoad) { - if (!options.columns && options.withRelated && _.intersection(this.eagerLoad, options.withRelated).length) { - this.query(withEager(this, this.eagerLoad)); - } - } -} - -/** - * ## Pagination - * Extends `bookshelf.Model` native `fetch` and `fetchAll` methods with - * a join to "eager loaded" relation. An exaple of such loading is when - * there is a need to order by fields in the related table. - */ -module.exports = function eagerLoadPlugin(Bookshelf) { - const modelPrototype = Bookshelf.Model.prototype; - - Bookshelf.Model = Bookshelf.Model.extend({ - initialize: function () { - return modelPrototype.initialize.apply(this, arguments); - }, - - fetch: function () { - load.apply(this, arguments); - - if (_debug.enabled('ghost-query')) { - debug('QUERY', this.query().toQuery()); - } - - return modelPrototype.fetch.apply(this, arguments); - }, - - fetchAll: function () { - load.apply(this, arguments); - - if (_debug.enabled('ghost-query')) { - debug('QUERY', this.query().toQuery()); - } - - return modelPrototype.fetchAll.apply(this, arguments); - } - }); -}; - -/** - * @typedef {import('bookshelf')} Bookshelf - */ diff --git a/core/server/models/plugins/filter.js b/core/server/models/plugins/filter.js deleted file mode 100644 index 4b0ba5c686..0000000000 --- a/core/server/models/plugins/filter.js +++ /dev/null @@ -1,157 +0,0 @@ -const debug = require('ghost-ignition').debug('models:plugins:filter'); -const errors = require('@tryghost/errors'); -const tpl = require('@tryghost/tpl'); - -const messages = { - errorParsing: 'Error parsing filter' -}; - -const RELATIONS = { - tags: { - tableName: 'tags', - type: 'manyToMany', - joinTable: 'posts_tags', - joinFrom: 'post_id', - joinTo: 'tag_id' - }, - authors: { - tableName: 'users', - tableNameAs: 'authors', - type: 'manyToMany', - joinTable: 'posts_authors', - joinFrom: 'post_id', - joinTo: 'author_id' - }, - labels: { - tableName: 'labels', - type: 'manyToMany', - joinTable: 'members_labels', - joinFrom: 'member_id', - joinTo: 'label_id' - }, - products: { - tableName: 'products', - type: 'manyToMany', - joinTable: 'members_products', - joinFrom: 'member_id', - joinTo: 'product_id' - }, - posts_meta: { - tableName: 'posts_meta', - type: 'oneToOne', - joinFrom: 'post_id' - } -}; - -const EXPANSIONS = { - posts: [{ - key: 'primary_tag', - replacement: 'tags.slug', - expansion: 'posts_tags.sort_order:0+tags.visibility:public' - }, { - key: 'primary_author', - replacement: 'authors.slug', - expansion: 'posts_authors.sort_order:0+authors.visibility:public' - }, { - key: 'authors', - replacement: 'authors.slug' - }, { - key: 'author', - replacement: 'authors.slug' - }, { - key: 'tag', - replacement: 'tags.slug' - }, { - key: 'tags', - replacement: 'tags.slug' - }], - members: [{ - key: 'label', - replacement: 'labels.slug' - }, { - key: 'labels', - replacement: 'labels.slug' - }, { - key: 'product', - replacement: 'products.slug' - }, { - key: 'products', - replacement: 'products.slug' - }] -}; - -/** - * @param {import('bookshelf')} Bookshelf - */ -const filter = function filter(Bookshelf) { - const Model = Bookshelf.Model.extend({ - // Cached copy of the filters setup for this model instance - _filters: null, - // Override these on the various models - enforcedFilters() {}, - defaultFilters() {}, - extraFilters() {}, - filterExpansions() {}, - /** - * Method which makes the necessary query builder calls (through knex) for the filters set on this model - * instance. - */ - applyDefaultAndCustomFilters: function applyDefaultAndCustomFilters(options) { - const nql = require('@nexes/nql'); - - const expansions = []; - - if (EXPANSIONS[this.tableName]) { - expansions.push(...EXPANSIONS[this.tableName]); - } - - if (this.filterExpansions()) { - expansions.push(...this.filterExpansions()); - } - - let custom = options.filter; - let extra = this.extraFilters(options); - let overrides = this.enforcedFilters(options); - let defaults = this.defaultFilters(options); - let transformer = options.mongoTransformer; - - debug('custom', custom); - debug('extra', extra); - debug('enforced', overrides); - debug('default', defaults); - - if (extra) { - if (custom) { - custom = `${custom}+${extra}`; - } else { - custom = extra; - } - } - - try { - this.query((qb) => { - nql(custom, { - relations: RELATIONS, - expansions: expansions, - overrides: overrides, - defaults: defaults, - transformer: transformer - }).querySQL(qb); - }); - } catch (err) { - throw new errors.BadRequestError({ - message: tpl(messages.errorParsing), - err - }); - } - } - }); - - Bookshelf.Model = Model; -}; - -/** - * ## Export Filter plugin - * @api public - */ -module.exports = filter; diff --git a/core/server/models/plugins/has-posts.js b/core/server/models/plugins/has-posts.js deleted file mode 100644 index 6a5bcca7d5..0000000000 --- a/core/server/models/plugins/has-posts.js +++ /dev/null @@ -1,63 +0,0 @@ -const _ = require('lodash'); -const _debug = require('ghost-ignition').debug._base; -const debug = _debug('ghost-query'); - -const addHasPostsWhere = (tableName, config) => { - const comparisonField = `${tableName}.id`; - - return function (qb) { - return qb.whereIn(comparisonField, function () { - const innerQb = this - .distinct(`${config.joinTable}.${config.joinTo}`) - .select() - .from(config.joinTable) - .whereRaw(`${config.joinTable}.${config.joinTo} = ${comparisonField}`) - .join('posts', 'posts.id', `${config.joinTable}.post_id`) - .andWhere('posts.status', '=', 'published'); - - debug(`QUERY has posts: ${innerQb.toSQL().sql}`); - - return innerQb; - }); - }; -}; - -/** - * @param {import('bookshelf')} Bookshelf - */ -const hasPosts = function hasPosts(Bookshelf) { - const modelPrototype = Bookshelf.Model.prototype; - - Bookshelf.Model = Bookshelf.Model.extend({ - initialize: function () { - return modelPrototype.initialize.apply(this, arguments); - }, - - fetch: function () { - if (this.shouldHavePosts) { - this.query(addHasPostsWhere(_.result(this, 'tableName'), this.shouldHavePosts)); - } - - if (_debug.enabled('ghost-query')) { - debug('QUERY', this.query().toQuery()); - } - - return modelPrototype.fetch.apply(this, arguments); - }, - - fetchAll: function () { - if (this.shouldHavePosts) { - this.query(addHasPostsWhere(_.result(this, 'tableName'), this.shouldHavePosts)); - } - - if (_debug.enabled('ghost-query')) { - debug('QUERY', this.query().toQuery()); - } - - return modelPrototype.fetchAll.apply(this, arguments); - } - }); -}; - -module.exports = hasPosts; -module.exports.addHasPostsWhere = addHasPostsWhere; diff --git a/core/server/models/plugins/include-count.js b/core/server/models/plugins/include-count.js deleted file mode 100644 index fe35ffb271..0000000000 --- a/core/server/models/plugins/include-count.js +++ /dev/null @@ -1,106 +0,0 @@ -const _debug = require('ghost-ignition').debug._base; -const debug = _debug('ghost-query'); -const _ = require('lodash'); - -/** - * @param {import('bookshelf')} Bookshelf - */ -module.exports = function (Bookshelf) { - const modelProto = Bookshelf.Model.prototype; - const countQueryBuilder = { - tags: { - posts: function addPostCountToTags(model, options) { - model.query('columns', 'tags.*', function (qb) { - qb.count('posts.id') - .from('posts') - .leftOuterJoin('posts_tags', 'posts.id', 'posts_tags.post_id') - .whereRaw('posts_tags.tag_id = tags.id') - .as('count__posts'); - - if (options.context && options.context.public) { - // @TODO use the filter behavior for posts - qb.andWhere('posts.type', '=', 'post'); - qb.andWhere('posts.status', '=', 'published'); - } - }); - } - }, - users: { - posts: function addPostCountToUsers(model, options) { - model.query('columns', 'users.*', function (qb) { - qb.count('posts.id') - .from('posts') - .join('posts_authors', 'posts.id', 'posts_authors.post_id') - .whereRaw('posts_authors.author_id = users.id') - .as('count__posts'); - - if (options.context && options.context.public) { - // @TODO use the filter behavior for posts - qb.andWhere('posts.type', '=', 'post'); - qb.andWhere('posts.status', '=', 'published'); - } - }); - } - } - }; - - const Model = Bookshelf.Model.extend({ - addCounts: function (options) { - if (!options) { - return; - } - - const tableName = _.result(this, 'tableName'); - - if (options.withRelated && options.withRelated.indexOf('count.posts') > -1) { - // remove post_count from withRelated - options.withRelated = _.pull([].concat(options.withRelated), 'count.posts'); - - // Call the query builder - countQueryBuilder[tableName].posts(this, options); - } - }, - fetch: function () { - this.addCounts.apply(this, arguments); - - // Useful when debugging no. database queries, GQL, etc - // To output this, use DEBUG=ghost:*,ghost-query - if (_debug.enabled('ghost-query')) { - debug('QUERY', this.query().toQuery()); - } - - // Call parent fetch - return modelProto.fetch.apply(this, arguments); - }, - fetchAll: function () { - this.addCounts.apply(this, arguments); - - // Useful when debugging no. database queries, GQL, etc - // To output this, use DEBUG=ghost:*,ghost-query - if (_debug.enabled('ghost-query')) { - debug('QUERY', this.query().toQuery()); - } - - // Call parent fetchAll - return modelProto.fetchAll.apply(this, arguments); - }, - - serialize: function serialize(options) { - const attrs = modelProto.serialize.call(this, options); - const countRegex = /^(count)(__)(.*)$/; - - _.forOwn(attrs, function (value, key) { - const match = key.match(countRegex); - if (match) { - attrs[match[1]] = attrs[match[1]] || {}; - attrs[match[1]][match[3]] = value; - delete attrs[key]; - } - }); - - return attrs; - } - }); - - Bookshelf.Model = Model; -}; diff --git a/core/server/models/plugins/index.js b/core/server/models/plugins/index.js deleted file mode 100644 index 7dd6cacd22..0000000000 --- a/core/server/models/plugins/index.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = { - eagerLoad: require('./eager-load'), - filter: require('./filter'), - order: require('./order'), - customQuery: require('./custom-query'), - search: require('./search'), - includeCount: require('./include-count'), - pagination: require('./pagination'), - collision: require('./collision'), - transactionEvents: require('./transaction-events'), - hasPosts: require('./has-posts') -}; diff --git a/core/server/models/plugins/order.js b/core/server/models/plugins/order.js deleted file mode 100644 index 840779aefd..0000000000 --- a/core/server/models/plugins/order.js +++ /dev/null @@ -1,80 +0,0 @@ -const _ = require('lodash'); - -/** - * @param {import('bookshelf')} Bookshelf - */ -const orderPlugin = function orderPlugin(Bookshelf) { - Bookshelf.Model = Bookshelf.Model.extend({ - orderAttributes() {}, - orderRawQuery() {}, - - parseOrderOption: function (orderQueryString, withRelated) { - const order = {}; - const orderRaw = []; - const eagerLoadArray = []; - - const orderAttributes = this.orderAttributes(); - if (withRelated && withRelated.indexOf('count.posts') > -1) { - orderAttributes.push('count.posts'); - } - - let rules = []; - // CASE: repeat order query parameter keys are present - if (_.isArray(orderQueryString)) { - orderQueryString.forEach((qs) => { - rules.push(...qs.split(',')); - }); - } else { - rules = orderQueryString.split(','); - } - - _.each(rules, (rule) => { - let match; - let field; - let direction; - - match = /^([a-z0-9_.]+)\s+(asc|desc)$/i.exec(rule.trim()); - - // invalid order syntax - if (!match) { - return; - } - - field = match[1].toLowerCase(); - direction = match[2].toUpperCase(); - - const orderRawQuery = this.orderRawQuery(field, direction, withRelated); - - if (orderRawQuery) { - orderRaw.push(orderRawQuery.orderByRaw); - if (orderRawQuery.eagerLoad) { - eagerLoadArray.push(orderRawQuery.eagerLoad); - } - return; - } - - const matchingOrderAttribute = orderAttributes.find((orderAttribute) => { - // NOTE: this logic assumes we use different field names for "parent" and "child" relations. - // E.g.: ['parent.title', 'child.title'] and ['child.title', 'parent.title'] - would not - // distinguish on which relation to sort neither which order to pick the fields on. - // For more context see: https://github.com/TryGhost/Ghost/pull/12226#discussion_r493085098 - return orderAttribute.endsWith(field); - }); - - if (!matchingOrderAttribute) { - return; - } - - order[matchingOrderAttribute] = direction; - }); - - return { - order, - orderRaw: orderRaw.join(', '), - eagerLoad: _.uniq(eagerLoadArray) - }; - } - }); -}; - -module.exports = orderPlugin; diff --git a/core/server/models/plugins/pagination.js b/core/server/models/plugins/pagination.js deleted file mode 100644 index 72f2cd0b19..0000000000 --- a/core/server/models/plugins/pagination.js +++ /dev/null @@ -1,278 +0,0 @@ -// # Pagination -// -// Extends Bookshelf.Model with a `fetchPage` method. Handles everything to do with paginated requests. -const _ = require('lodash'); - -const errors = require('@tryghost/errors'); -const tpl = require('@tryghost/tpl'); - -const messages = { - pageNotFound: 'Page not found', - couldNotUnderstandRequest: 'Could not understand request.' -}; - -let defaults; -let paginationUtils; - -/** - * ### Default pagination values - * These are overridden via `options` passed to each function - * @typedef {Object} defaults - * @default - * @property {Number} `page` \- page in set to display (default: 1) - * @property {Number|String} `limit` \- no. results per page (default: 15) - */ -defaults = { - page: 1, - limit: 15 -}; - -/** - * ## Pagination Utils - * @api private - */ -paginationUtils = { - /** - * ### Parse Options - * Take the given options and ensure they are valid pagination options, else use the defaults - * @param {Object} options - * @returns {Object} options sanitised for pagination - */ - parseOptions: function parseOptions(options) { - options = _.defaults(options || {}, defaults); - - if (options.limit !== 'all') { - options.limit = parseInt(options.limit, 10) || defaults.limit; - } - - options.page = parseInt(options.page, 10) || defaults.page; - - return options; - }, - /** - * ### Query - * Apply the necessary parameters to paginate the query - * @param {Bookshelf['Model']} model - * @param {Object} options - */ - addLimitAndOffset: function addLimitAndOffset(model, options) { - if (_.isNumber(options.limit)) { - model - .query('limit', options.limit) - .query('offset', options.limit * (options.page - 1)); - } - }, - - /** - * ### Format Response - * Takes the no. items returned and original options and calculates all of the pagination meta data - * @param {Number} totalItems - * @param {Object} options - * @returns {pagination} pagination metadata - */ - formatResponse: function formatResponse(totalItems, options) { - const calcPages = Math.ceil(totalItems / options.limit) || 0; - - const pagination = { - page: options.page || defaults.page, - limit: options.limit, - pages: calcPages === 0 ? 1 : calcPages, - total: totalItems, - next: null, - prev: null - }; - - if (pagination.pages > 1) { - if (pagination.page === 1) { - pagination.next = pagination.page + 1; - } else if (pagination.page === pagination.pages) { - pagination.prev = pagination.page - 1; - } else { - pagination.next = pagination.page + 1; - pagination.prev = pagination.page - 1; - } - } - - return pagination; - }, - - /** - * - * @param {Bookshelf['Model']} model instance of Bookshelf model - * @param {string} propertyName property to be inspected and included in the relation - */ - handleRelation: function handleRelation(model, propertyName) { - const tableName = _.result(model.constructor.prototype, 'tableName'); - - const targetTable = propertyName.includes('.') && propertyName.split('.')[0]; - - if (targetTable && targetTable !== tableName) { - if (!model.eagerLoad) { - model.eagerLoad = []; - } - - if (!model.eagerLoad.includes(targetTable)) { - model.eagerLoad.push(targetTable); - } - } - } -}; - -// ## Object Definitions - -/** - * ### Pagination Object - * @typedef {Object} pagination - * @property {Number} page \- page in set to display - * @property {Number|String} limit \- no. results per page, or 'all' - * @property {Number} pages \- total no. pages in the full set - * @property {Number} total \- total no. items in the full set - * @property {Number|null} next \- next page - * @property {Number|null} prev \- previous page - */ - -/** - * ### Fetch Page Options - * @typedef {Object} options - * @property {Number} page \- page in set to display - * @property {Number|String} limit \- no. results per page, or 'all' - * @property {Object} order \- set of order by params and directions - */ - -/** - * ### Fetch Page Response - * @typedef {Object} paginatedResult - * @property {Array} collection \- set of results - * @property {pagination} pagination \- pagination metadata - */ - -/** - * ## Pagination - * Extends `bookshelf.Model` with `fetchPage` - * @param {Bookshelf} bookshelf \- the instance to plug into - */ -const pagination = function pagination(bookshelf) { - // Extend updates the first object passed to it, no need for an assignment - _.extend(bookshelf.Model.prototype, { - /** - * ### Fetch page - * A `fetch` extension to get a paginated set of items from a collection - * - * We trigger two queries: - * 1. count query to know how many pages left (important: we don't attach any group/order statements!) - * 2. the actualy fetch query with limit and page property - * - * @param {Object} options - * @returns {Promise} set of results + pagination metadata - */ - fetchPage: function fetchPage(options) { - // Setup pagination options - options = paginationUtils.parseOptions(options); - - // Get the table name and idAttribute for this model - const tableName = _.result(this.constructor.prototype, 'tableName'); - - const idAttribute = _.result(this.constructor.prototype, 'idAttribute'); - const self = this; - - // #### Pre count clauses - // Add any where or join clauses which need to be included with the aggregate query - - // Clone the base query & set up a promise to get the count of total items in the full set - // Necessary due to lack of support for `count distinct` in bookshelf's count() - // Skipped if limit='all' as we can use the length of the fetched data set - let countPromise = Promise.resolve(); - if (options.limit !== 'all') { - const countQuery = this.query().clone(); - - if (options.transacting) { - countQuery.transacting(options.transacting); - } - - countPromise = countQuery.select( - bookshelf.knex.raw('count(distinct ' + tableName + '.' + idAttribute + ') as aggregate') - ); - } - - return countPromise.then(function (countResult) { - // #### Post count clauses - // Add any where or join clauses which need to NOT be included with the aggregate query - - // Setup the pagination parameters so that we return the correct items from the set - paginationUtils.addLimitAndOffset(self, options); - - // Apply ordering options if they are present - if (options.order && !_.isEmpty(options.order)) { - _.forOwn(options.order, function (direction, property) { - if (property === 'count.posts') { - self.query('orderBy', 'count__posts', direction); - } else { - self.query('orderBy', property, direction); - - paginationUtils.handleRelation(self, property); - } - }); - } - - if (options.orderRaw) { - self.query((qb) => { - qb.orderByRaw(options.orderRaw); - }); - } - - if (!_.isEmpty(options.eagerLoad)) { - options.eagerLoad.forEach(property => paginationUtils.handleRelation(self, property)); - } - - if (options.groups && !_.isEmpty(options.groups)) { - _.each(options.groups, function (group) { - self.query('groupBy', group); - }); - } - - // Setup the promise to do a fetch on our collection, running the specified query - // @TODO: ensure option handling is done using an explicit pick elsewhere - - return self.fetchAll(_.omit(options, ['page', 'limit'])) - .then(function (fetchResult) { - if (options.limit === 'all') { - countResult = [{aggregate: fetchResult.length}]; - } - - return { - collection: fetchResult, - pagination: paginationUtils.formatResponse(countResult[0] ? countResult[0].aggregate : 0, options) - }; - }) - .catch(function (err) { - // e.g. offset/limit reached max allowed integer value - if (err.errno === 20 || err.errno === 1064) { - throw new errors.NotFoundError({message: tpl(messages.pageNotFound)}); - } - - throw err; - }); - }).catch((err) => { - // CASE: SQL syntax is incorrect - if (err.errno === 1054 || err.errno === 1) { - throw new errors.BadRequestError({ - message: tpl(messages.couldNotUnderstandRequest), - err - }); - } - - throw err; - }); - } - }); -}; - -/** - * @typedef {import('bookshelf')} Bookshelf - */ - -/** - * ## Export pagination plugin - * @api public - */ -module.exports = pagination; diff --git a/core/server/models/plugins/search.js b/core/server/models/plugins/search.js deleted file mode 100644 index 2014286298..0000000000 --- a/core/server/models/plugins/search.js +++ /dev/null @@ -1,18 +0,0 @@ -const searchPlugin = function searchPlugin(Bookshelf) { - const Model = Bookshelf.Model.extend({ - // override this on the model itself - searchQuery() {}, - - applySearchQuery: function applySearchQuery(options) { - if (options.search) { - this.query((qb) => { - this.searchQuery(qb, options.search); - }); - } - } - }); - - Bookshelf.Model = Model; -}; - -module.exports = searchPlugin; diff --git a/core/server/models/plugins/transaction-events.js b/core/server/models/plugins/transaction-events.js deleted file mode 100644 index f1ad023b82..0000000000 --- a/core/server/models/plugins/transaction-events.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * This is a feature request in knex for 1.0. - * https://github.com/tgriesser/knex/issues/1641 - */ -module.exports = function (bookshelf) { - const orig1 = bookshelf.transaction; - - bookshelf.transaction = function (cb) { - return orig1.bind(bookshelf)(function (t) { - const orig2 = t.commit; - const orig3 = t.rollback; - - t.commit = function () { - t.emit('committed', true); - return orig2.apply(t, arguments); - }; - - t.rollback = function () { - t.emit('committed', false); - return orig3.apply(t, arguments); - }; - - return cb(t); - }); - }; -}; diff --git a/package.json b/package.json index 520b1a2ab0..30de1bcf73 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@sentry/node": "6.7.0", "@tryghost/adapter-manager": "0.2.13", "@tryghost/admin-api-schema": "2.3.0", + "@tryghost/bookshelf-plugins": "0.1.1", "@tryghost/bootstrap-socket": "0.2.9", "@tryghost/constants": "0.1.8", "@tryghost/email-analytics-provider-mailgun": "1.0.0", diff --git a/test/unit/models/plugins/pagination_spec.js b/test/unit/models/plugins/pagination_spec.js deleted file mode 100644 index b6e5acb3a1..0000000000 --- a/test/unit/models/plugins/pagination_spec.js +++ /dev/null @@ -1,350 +0,0 @@ -const should = require('should'); -const sinon = require('sinon'); -const Promise = require('bluebird'); -const rewire = require('rewire'); -const pagination = rewire('../../../../core/server/models/plugins/pagination'); - -describe('pagination', function () { - let paginationUtils; - - afterEach(function () { - sinon.restore(); - }); - - describe('paginationUtils', function () { - before(function () { - paginationUtils = pagination.__get__('paginationUtils'); - }); - - describe('formatResponse', function () { - let formatResponse; - - before(function () { - formatResponse = paginationUtils.formatResponse; - }); - - it('returns correct pagination object for single page', function () { - formatResponse(5, {limit: 10, page: 1}).should.eql({ - limit: 10, - next: null, - page: 1, - pages: 1, - prev: null, - total: 5 - }); - }); - - it('returns correct pagination object for first page of many', function () { - formatResponse(44, {limit: 5, page: 1}).should.eql({ - limit: 5, - next: 2, - page: 1, - pages: 9, - prev: null, - total: 44 - }); - }); - - it('returns correct pagination object for middle page of many', function () { - formatResponse(44, {limit: 5, page: 9}).should.eql({ - limit: 5, - next: null, - page: 9, - pages: 9, - prev: 8, - total: 44 - }); - }); - - it('returns correct pagination object for last page of many', function () { - formatResponse(44, {limit: 5, page: 3}).should.eql({ - limit: 5, - next: 4, - page: 3, - pages: 9, - prev: 2, - total: 44 - }); - }); - - it('returns correct pagination object when page not set', function () { - formatResponse(5, {limit: 10}).should.eql({ - limit: 10, - next: null, - page: 1, - pages: 1, - prev: null, - total: 5 - }); - }); - - it('returns correct pagination object for limit all', function () { - formatResponse(5, {limit: 'all'}).should.eql({ - limit: 'all', - next: null, - page: 1, - pages: 1, - prev: null, - total: 5 - }); - }); - }); - - describe('parseOptions', function () { - let parseOptions; - - before(function () { - parseOptions = paginationUtils.parseOptions; - }); - - it('should use defaults if no options are passed', function () { - parseOptions().should.eql({ - limit: 15, - page: 1 - }); - }); - - it('should accept numbers for limit and page', function () { - parseOptions({ - limit: 10, - page: 2 - }).should.eql({ - limit: 10, - page: 2 - }); - }); - - it('should use defaults if bad options are passed', function () { - parseOptions({ - limit: 'thelma', - page: 'louise' - }).should.eql({ - limit: 15, - page: 1 - }); - }); - - it('should permit all for limit', function () { - parseOptions({ - limit: 'all' - }).should.eql({ - limit: 'all', - page: 1 - }); - }); - }); - - describe('addLimitAndOffset', function () { - let addLimitAndOffset; - const collection = {}; - - before(function () { - addLimitAndOffset = paginationUtils.addLimitAndOffset; - }); - - beforeEach(function () { - collection.query = sinon.stub().returns(collection); - }); - - it('should add query options if limit is set', function () { - addLimitAndOffset(collection, {limit: 5, page: 1}); - - collection.query.calledTwice.should.be.true(); - collection.query.firstCall.calledWith('limit', 5).should.be.true(); - collection.query.secondCall.calledWith('offset', 0).should.be.true(); - }); - - it('should not add query options if limit is not set', function () { - addLimitAndOffset(collection, {page: 1}); - - collection.query.called.should.be.false(); - }); - }); - }); - - describe('fetchPage', function () { - let model; - let bookshelf; - let knex; - let mockQuery; - - before(function () { - paginationUtils = pagination.__get__('paginationUtils'); - }); - - beforeEach(function () { - // Stub paginationUtils - paginationUtils.parseOptions = sinon.stub(); - paginationUtils.addLimitAndOffset = sinon.stub(); - paginationUtils.formatResponse = sinon.stub().returns({}); - - // Mock out bookshelf model - mockQuery = { - clone: sinon.stub(), - select: sinon.stub(), - toQuery: sinon.stub() - }; - mockQuery.clone.returns(mockQuery); - mockQuery.select.returns(Promise.resolve([{aggregate: 1}])); - - model = function () { - }; - - model.prototype.fetchAll = sinon.stub().returns(Promise.resolve({})); - model.prototype.query = sinon.stub(); - model.prototype.query.returns(mockQuery); - - knex = {raw: sinon.stub().returns(Promise.resolve())}; - - bookshelf = {Model: model, knex: knex}; - - pagination(bookshelf); - }); - - it('extends Model with fetchPage', function () { - bookshelf.Model.prototype.should.have.ownProperty('fetchPage'); - bookshelf.Model.prototype.fetchPage.should.be.a.Function(); - }); - - it('calls all paginationUtils and methods', function (done) { - paginationUtils.parseOptions.returns({}); - - bookshelf.Model.prototype.fetchPage().then(function () { - sinon.assert.callOrder( - paginationUtils.parseOptions, - model.prototype.query, - mockQuery.clone, - mockQuery.select, - paginationUtils.addLimitAndOffset, - model.prototype.fetchAll, - paginationUtils.formatResponse - ); - - paginationUtils.parseOptions.calledOnce.should.be.true(); - paginationUtils.parseOptions.calledWith(undefined).should.be.true(); - - paginationUtils.addLimitAndOffset.calledOnce.should.be.true(); - paginationUtils.formatResponse.calledOnce.should.be.true(); - - model.prototype.query.calledOnce.should.be.true(); - model.prototype.query.firstCall.calledWith().should.be.true(); - - mockQuery.clone.calledOnce.should.be.true(); - mockQuery.clone.firstCall.calledWith().should.be.true(); - - mockQuery.select.calledOnce.should.be.true(); - mockQuery.select.calledWith().should.be.true(); - - model.prototype.fetchAll.calledOnce.should.be.true(); - model.prototype.fetchAll.calledWith({}).should.be.true(); - - done(); - }).catch(done); - }); - - it('calls all paginationUtils and methods when order set', function (done) { - const orderOptions = {order: {id: 'DESC'}}; - paginationUtils.parseOptions.returns(orderOptions); - - bookshelf.Model.prototype.fetchPage(orderOptions).then(function () { - sinon.assert.callOrder( - paginationUtils.parseOptions, - model.prototype.query, - mockQuery.clone, - mockQuery.select, - paginationUtils.addLimitAndOffset, - model.prototype.query, - model.prototype.fetchAll, - paginationUtils.formatResponse - ); - - paginationUtils.parseOptions.calledOnce.should.be.true(); - paginationUtils.parseOptions.calledWith(orderOptions).should.be.true(); - - paginationUtils.addLimitAndOffset.calledOnce.should.be.true(); - paginationUtils.formatResponse.calledOnce.should.be.true(); - - model.prototype.query.calledTwice.should.be.true(); - model.prototype.query.firstCall.calledWith().should.be.true(); - model.prototype.query.secondCall.calledWith('orderBy', 'id', 'DESC').should.be.true(); - - mockQuery.clone.calledOnce.should.be.true(); - mockQuery.clone.firstCall.calledWith().should.be.true(); - - mockQuery.select.calledOnce.should.be.true(); - mockQuery.select.calledWith().should.be.true(); - - model.prototype.fetchAll.calledOnce.should.be.true(); - model.prototype.fetchAll.calledWith(orderOptions).should.be.true(); - - done(); - }).catch(done); - }); - - it('calls all paginationUtils and methods when group by set', function (done) { - const groupOptions = {groups: ['posts.id']}; - paginationUtils.parseOptions.returns(groupOptions); - - bookshelf.Model.prototype.fetchPage(groupOptions).then(function () { - sinon.assert.callOrder( - paginationUtils.parseOptions, - model.prototype.query, - mockQuery.clone, - mockQuery.select, - paginationUtils.addLimitAndOffset, - model.prototype.query, - model.prototype.fetchAll, - paginationUtils.formatResponse - ); - - paginationUtils.parseOptions.calledOnce.should.be.true(); - paginationUtils.parseOptions.calledWith(groupOptions).should.be.true(); - - paginationUtils.addLimitAndOffset.calledOnce.should.be.true(); - paginationUtils.formatResponse.calledOnce.should.be.true(); - - model.prototype.query.calledTwice.should.be.true(); - model.prototype.query.firstCall.calledWith().should.be.true(); - model.prototype.query.secondCall.calledWith('groupBy', 'posts.id').should.be.true(); - - mockQuery.clone.calledOnce.should.be.true(); - mockQuery.clone.firstCall.calledWith().should.be.true(); - - mockQuery.select.calledOnce.should.be.true(); - mockQuery.select.calledWith().should.be.true(); - - model.prototype.fetchAll.calledOnce.should.be.true(); - model.prototype.fetchAll.calledWith(groupOptions).should.be.true(); - - done(); - }).catch(done); - }); - - it('returns expected response', function (done) { - paginationUtils.parseOptions.returns({}); - bookshelf.Model.prototype.fetchPage().then(function (result) { - result.should.have.ownProperty('collection'); - result.should.have.ownProperty('pagination'); - result.collection.should.be.an.Object(); - result.pagination.should.be.an.Object(); - - done(); - }); - }); - - it('returns expected response even when aggregate is empty', function (done) { - // override aggregate response - mockQuery.select.returns(Promise.resolve([])); - paginationUtils.parseOptions.returns({}); - - bookshelf.Model.prototype.fetchPage().then(function (result) { - result.should.have.ownProperty('collection'); - result.should.have.ownProperty('pagination'); - result.collection.should.be.an.Object(); - result.pagination.should.be.an.Object(); - - done(); - }); - }); - }); -}); diff --git a/yarn.lock b/yarn.lock index eb75bee2e6..560d1dd78f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -324,7 +324,7 @@ resolved "https://registry.yarnpkg.com/@nexes/nql-lang/-/nql-lang-0.0.1.tgz#a13c023873f9bc11b9e4e284449c6cfbeccc8011" integrity sha1-oTwCOHP5vBG55OKERJxs++zMgBE= -"@nexes/nql@0.5.2": +"@nexes/nql@0.5.2", "@nexes/nql@^0.5.2": version "0.5.2" resolved "https://registry.yarnpkg.com/@nexes/nql/-/nql-0.5.2.tgz#64d847d563720d9c3a0f9683dde930fee518e064" integrity sha512-qGLwtpYkKoHI++b+zgsEHuISDUv7LC1PI/0bWd6X9bOz2zGj1nJUDFJHjwdOuLmV5q6BR60VDBKw51Mvzqkl2g== @@ -596,6 +596,92 @@ ghost-ignition "^4.6.2" lodash "^4.17.11" +"@tryghost/bookshelf-collision@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-collision/-/bookshelf-collision-0.1.0.tgz#6cfe9735b50122c0a01741effda223b013260ee0" + integrity sha512-2Cs/dDg1IrDDNHs/ogz07VVAILJndODOIXRVkwLdZiEfEaiFISzi3sb1UWbDkZGmiYzjOIwZ7/ifB28kqDvYHg== + dependencies: + "@tryghost/errors" "^0.2.12" + lodash "^4.17.21" + moment-timezone "^0.5.33" + +"@tryghost/bookshelf-custom-query@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-custom-query/-/bookshelf-custom-query-0.1.0.tgz#17a511f83fe51c80007563df28b35776d4092846" + integrity sha512-sJCgqVBpQjhZohgUeV+e6EC3yDd7WJgkGP+LSsSvxRq/wxf7RxbkkTwAgd3XPiyi4LcHz/9a2Sr8HBgVDsXLIQ== + +"@tryghost/bookshelf-eager-load@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-eager-load/-/bookshelf-eager-load-0.1.1.tgz#3c0b8b28669e0dedbe46c33cb3efd56e49b380af" + integrity sha512-o/JzO7Xt7cTdHw9mrwkSaJ2uQKhmzRLvZSt7PyFx/gecD//OyajN7VTU6OfuBQ85gHaYrRAc3YRI047U0ik/eQ== + dependencies: + lodash "^4.17.21" + +"@tryghost/bookshelf-filter@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-filter/-/bookshelf-filter-0.1.1.tgz#4976d498e28b3a48ec3cc1f6a3a69225e3fe0227" + integrity sha512-np/J6+Lzqa9NIFhr69pE3E79XBGUinOSzvUtk6pxMukxTJtqAPkwjFOoZrAe21HHkmdzsQ51bvDAYHAPgVKHxA== + dependencies: + "@nexes/nql" "^0.5.2" + "@tryghost/errors" "^0.2.12" + "@tryghost/tpl" "^0.1.1" + +"@tryghost/bookshelf-has-posts@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-has-posts/-/bookshelf-has-posts-0.1.1.tgz#9ee97057bed61f964b5b7abc79c2073f37dedac5" + integrity sha512-zfWKA3CRE/zhV2L7vYR7zQKIn75pYPY4EExDaxpixy77mMoTactXpofsYy1leJ1MwLR0RW850MWRavIN9PTXEA== + dependencies: + "@tryghost/debug" "^0.1.0" + lodash "^4.17.21" + +"@tryghost/bookshelf-include-count@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-include-count/-/bookshelf-include-count-0.1.1.tgz#ef1bffc91dfe17934c6aadf298f9d8fc2200d367" + integrity sha512-9sIei0t1ymgBha+b7vZpsStkclYXV+EHVO9LTl/WY2V2TYa4RpnbIaa7NELF/1OzQ54Iy67yqqf9J2FhtgiFWA== + dependencies: + lodash "^4.17.21" + +"@tryghost/bookshelf-order@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-order/-/bookshelf-order-0.1.0.tgz#7666e3135ab0322531f610ea5554da227d767087" + integrity sha512-mBJYHPw7f+cpZlkfNB5//sE8/yVscHZYhiUKiebSGZVV1CqyzixQ9NPIOsc2oRk4xiG6uHTxhH6WH4MXKTIpOA== + dependencies: + lodash "^4.17.21" + +"@tryghost/bookshelf-pagination@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-pagination/-/bookshelf-pagination-0.1.1.tgz#3177f89f88747ed888db8276d946651f69736729" + integrity sha512-aZrbFOnuukB+bBmcw97MxNNrVok8BP1DHgPTY8hUaERQTU009ACHb/Mtb89fMExB/qFbz4UYuToUcGqEvRR7kw== + dependencies: + "@tryghost/errors" "^0.2.12" + "@tryghost/tpl" "^0.1.1" + +"@tryghost/bookshelf-plugins@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-plugins/-/bookshelf-plugins-0.1.1.tgz#c75db06c15e5680036915bfea4fb89dd485c0678" + integrity sha512-b10rPGFWEkjRrO1mB+1XELBHwEOa3er9NWkTcak7thDTVnBnSjJImAdPNVt10gEck6sB8EgS8C7UyZJcGxS/gA== + dependencies: + "@tryghost/bookshelf-collision" "^0.1.0" + "@tryghost/bookshelf-custom-query" "^0.1.0" + "@tryghost/bookshelf-eager-load" "^0.1.1" + "@tryghost/bookshelf-filter" "^0.1.1" + "@tryghost/bookshelf-has-posts" "^0.1.1" + "@tryghost/bookshelf-include-count" "^0.1.1" + "@tryghost/bookshelf-order" "^0.1.0" + "@tryghost/bookshelf-pagination" "^0.1.1" + "@tryghost/bookshelf-search" "^0.1.0" + "@tryghost/bookshelf-transaction-events" "^0.1.0" + +"@tryghost/bookshelf-search@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-search/-/bookshelf-search-0.1.0.tgz#34e22fe6fb49cde7d1755acb4baaf156e0a2e8e7" + integrity sha512-O6KZMz9+bnTLX6ZGAiQCI7EQ+JMlIPQX2CezxECUzh+GueTJ+31Ai6o8hhA0QUQpvp2GXDBoGhe54dW1cG5yTw== + +"@tryghost/bookshelf-transaction-events@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@tryghost/bookshelf-transaction-events/-/bookshelf-transaction-events-0.1.0.tgz#cb49e567777aeeb4e911fb9a6421e8450fd6ef15" + integrity sha512-WlBTOe+/BtJWTIaH1sOay8d1uGOlHGzprg68E07WzgX6S+dEgUaazZvyFpfHcJxkjb51ysI4qG+6OJf1QUXaWw== + "@tryghost/bootstrap-socket@0.2.9": version "0.2.9" resolved "https://registry.yarnpkg.com/@tryghost/bootstrap-socket/-/bootstrap-socket-0.2.9.tgz#441e9287c4293fc344fc363a4ea8579144393ea6" @@ -613,6 +699,13 @@ resolved "https://registry.yarnpkg.com/@tryghost/constants/-/constants-0.1.8.tgz#b08074cdc6f8a379209750e60c2ab62c8ba895cf" integrity sha512-3/w0k2JlpYjG/3tU3zjvVlrNH+kK9bjZvp7xaCkvmbsLGxQFMtlKU4fFY+rcpDVWOkywIHmKbLGmAqADgZeakA== +"@tryghost/debug@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@tryghost/debug/-/debug-0.1.0.tgz#f55dc680b07fe49556c436d43e93e726e6ecbfc3" + integrity sha512-ZoA6I9cjGMnJ4Nfcm4isOcxQEcm9Rhd1CmiBOXvTBsk442h/H4BIoOOY2Wy55oUiOf4rWxpqi9Duv/rQxJjkpA== + dependencies: + "@tryghost/root-utils" "^0.2.2" + "@tryghost/elasticsearch-bunyan@0.1.1": version "0.1.1" resolved "https://registry.yarnpkg.com/@tryghost/elasticsearch-bunyan/-/elasticsearch-bunyan-0.1.1.tgz#5a36d81dd020825dd563b1357ae6c249580c46f5" @@ -848,6 +941,14 @@ caller "^1.0.1" find-root "^1.1.0" +"@tryghost/root-utils@^0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@tryghost/root-utils/-/root-utils-0.2.2.tgz#bdd22faf5bd6628494748363e69de4afc8563a0e" + integrity sha512-U2CLiEalfRX1R+7o+YrhzoIy3F7F0GvZKSMN9h5u7tuFWLq6U92QzpSHhpAFtpzgmGhIzOVsVtnVjntaUE0LlA== + dependencies: + caller "^1.0.1" + find-root "^1.1.0" + "@tryghost/security@0.2.9": version "0.2.9" resolved "https://registry.yarnpkg.com/@tryghost/security/-/security-0.2.9.tgz#1bc323b22774c122c54a1b43a2594cdc88feba71" @@ -6994,7 +7095,7 @@ module-not-found-error@^1.0.1: resolved "https://registry.yarnpkg.com/module-not-found-error/-/module-not-found-error-1.0.1.tgz#cf8b4ff4f29640674d6cdd02b0e3bc523c2bbdc0" integrity sha1-z4tP9PKWQGdNbN0CsOO8UjwrvcA= -moment-timezone@0.5.23, moment-timezone@^0.5.31: +moment-timezone@0.5.23, moment-timezone@^0.5.31, moment-timezone@^0.5.33: version "0.5.23" resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.23.tgz#7cbb00db2c14c71b19303cb47b0fb0a6d8651463" integrity sha512-WHFH85DkCfiNMDX5D3X7hpNH3/PUhjTGcD0U1SgfBGZxJ3qUmJh5FdvaFjcClxOvB3rzdfj4oRffbI38jEnC1w==