UNPKG

ghost

Version:

The professional publishing platform

311 lines (265 loc) 10.4 kB
const _ = require('lodash'); const debug = require('@tryghost/debug')('api:endpoints:utils:serializers:input:posts'); const {ValidationError} = require('@tryghost/errors'); const tpl = require('@tryghost/tpl'); const url = require('./utils/url'); const slugFilterOrder = require('./utils/slug-filter-order'); const localUtils = require('../../index'); const mobiledoc = require('../../../../../lib/mobiledoc'); const postsMetaSchema = require('../../../../../data/schema').tables.posts_meta; const postsSchema = require('../../../../../data/schema').tables.posts; const clean = require('./utils/clean'); const lexical = require('../../../../../lib/lexical'); const sentry = require('../../../../../../shared/sentry'); const messages = { failedHtmlToMobiledoc: 'Failed to convert HTML to Mobiledoc', failedHtmlToLexical: 'Failed to convert HTML to Lexical' }; /** * Selects all allowed columns for the given frame. * * NOTE: This doesn't stop them from being FETCHED, just returned in the response. This causes * the output serializer to remove them from the data object before returning. * * NOTE: This is only intended for the Content API. We need these fields within Admin API responses. * * @param {Object} frame - The frame object. */ function removeSourceFormats(frame) { if (frame.options.formats?.includes('mobiledoc') || frame.options.formats?.includes('lexical')) { frame.options.formats = frame.options.formats.filter((format) => { return !['mobiledoc', 'lexical'].includes(format); }); } } /** * Selects all allowed columns for the given frame. * * This removes the lexical and mobiledoc columns from the query. This is a performance improvement as we never intend * to expose those columns in the content API and they are very large datasets to be passing around and de/serializing. * * NOTE: This is only intended for the Content API. We need these fields within Admin API responses. * * @param {Object} frame - The frame object. */ function selectAllAllowedColumns(frame) { if (!frame.options.columns && !frame.options.selectRaw) { // Because we're returning columns directly from the schema we need to remove info columns like @@UNIQUE_CONSTRAINTS@@ and @@INDEXES@@ frame.options.selectRaw = _.keys(_.omit(postsSchema, ['lexical','mobiledoc','@@INDEXES@@','@@UNIQUE_CONSTRAINTS@@'])).join(','); } else if (frame.options.columns) { frame.options.columns = frame.options.columns.filter((column) => { return !['mobiledoc', 'lexical'].includes(column); }); } else if (frame.options.selectRaw) { frame.options.selectRaw = frame.options.selectRaw.split(',').map((column) => { return column.trim(); }).filter((column) => { return !['mobiledoc', 'lexical'].includes(column); }).join(','); } } /** * Map names of relations to the internal names */ function mapWithRelated(frame) { if (frame.options.withRelated) { // Map sentiment to count.sentiment frame.options.withRelated = frame.options.withRelated.map((relation) => { return relation === 'sentiment' ? 'count.sentiment' : relation; }); return; } } function defaultRelations(frame) { // Apply same mapping as content API mapWithRelated(frame); // Additional defaults for admin API if (frame.options.withRelated) { return; } if (frame.options.columns && !frame.options.withRelated) { return false; } frame.options.withRelated = ['tags', 'authors', 'authors.roles', 'email', 'tiers', 'newsletter', 'count.clicks']; } function setDefaultOrder(frame) { if (!frame.options.order && frame.options.filter) { frame.options.autoOrder = slugFilterOrder('posts', frame.options.filter); } if (!frame.options.order && !frame.options.autoOrder) { frame.options.order = 'published_at desc'; } } function forceVisibilityColumn(frame) { if (frame.options.columns && !frame.options.columns.includes('visibility')) { frame.options.columns.push('visibility'); } } function defaultFormat(frame) { if (frame.options.formats) { return; } frame.options.formats = 'mobiledoc,lexical'; } function handlePostsMeta(frame) { let metaAttrs = _.keys(_.omit(postsMetaSchema, ['id', 'post_id'])); let meta = _.pick(frame.data.posts[0], metaAttrs); frame.data.posts[0].posts_meta = meta; } /** * CASE: * * - posts endpoint only returns posts, not pages * - we have to enforce the filter * * @TODO: https://github.com/TryGhost/Ghost/issues/10268 */ const forcePageFilter = (frame) => { if (frame.options.filter) { frame.options.filter = `(${frame.options.filter})+type:post`; } else { frame.options.filter = 'type:post'; } }; const forceStatusFilter = (frame) => { if (!frame.options.filter) { frame.options.filter = 'status:[draft,published,scheduled,sent]'; } else if (!frame.options.filter.match(/status:/)) { frame.options.filter = `(${frame.options.filter})+status:[draft,published,scheduled,sent]`; } }; module.exports = { browse(apiConfig, frame) { debug('browse'); forcePageFilter(frame); /** * ## current cases: * - context object is empty (functional call, content api access) * - api_key.type == 'content' ? content api access * - user exists? admin api access */ if (localUtils.isContentAPI(frame)) { // CASE: the content api endpoint for posts should not return mobiledoc or lexical removeSourceFormats(frame); // remove from the format field selectAllAllowedColumns(frame); // remove from any specified column or selectRaw options setDefaultOrder(frame); forceVisibilityColumn(frame); mapWithRelated(frame); } if (!localUtils.isContentAPI(frame)) { forceStatusFilter(frame); defaultFormat(frame); defaultRelations(frame); } }, read(apiConfig, frame) { debug('read'); forcePageFilter(frame); /** * ## current cases: * - context object is empty (functional call, content api access) * - api_key.type == 'content' ? content api access * - user exists? admin api access */ if (localUtils.isContentAPI(frame)) { // CASE: the content api endpoint for posts should not return mobiledoc or lexical removeSourceFormats(frame); setDefaultOrder(frame); forceVisibilityColumn(frame); } if (!localUtils.isContentAPI(frame)) { forceStatusFilter(frame); defaultFormat(frame); defaultRelations(frame); } }, add(apiConfig, frame, options = {add: true}) { debug('add'); if (_.get(frame,'options.source')) { const html = frame.data.posts[0].html; if (frame.options.source === 'html' && !_.isEmpty(html)) { if (process.env.CI) { console.time('htmlToMobiledocConverter (post)'); // eslint-disable-line no-console } try { frame.data.posts[0].mobiledoc = JSON.stringify(mobiledoc.htmlToMobiledocConverter(html)); } catch (err) { sentry.captureException(err); throw new ValidationError({ message: tpl(messages.failedHtmlToMobiledoc), err }); } if (process.env.CI) { console.timeEnd('htmlToMobiledocConverter (post)'); // eslint-disable-line no-console } // normally we don't allow both mobiledoc+lexical but the model layer will remove lexical // if mobiledoc is already present to avoid migrating formats outside of an explicit conversion if (process.env.CI) { console.time('htmlToLexicalConverter (post)'); // eslint-disable-line no-console } try { frame.data.posts[0].lexical = JSON.stringify(lexical.htmlToLexicalConverter(html)); } catch (err) { sentry.captureException(err); throw new ValidationError({ message: tpl(messages.failedHtmlToLexical), err }); } if (process.env.CI) { console.timeEnd('htmlToLexicalConverter (post)'); // eslint-disable-line no-console } } } frame.data.posts[0] = url.forPost(Object.assign({}, frame.data.posts[0]), frame.options); // @NOTE: force adding post if (options.add) { frame.data.posts[0].type = 'post'; } // CASE: Transform short to long format if (frame.data.posts[0].authors) { frame.data.posts[0].authors.forEach((author, index) => { if (_.isString(author)) { frame.data.posts[0].authors[index] = { email: author }; } }); } if (frame.data.posts[0].tags) { frame.data.posts[0].tags.forEach((tag, index) => { if (_.isString(tag)) { frame.data.posts[0].tags[index] = { name: tag }; } else { frame.data.posts[0].tags[index] = clean.postsTag(tag); } }); } handlePostsMeta(frame); defaultFormat(frame); defaultRelations(frame); }, edit(apiConfig, frame) { debug('edit'); this.add(apiConfig, frame, {add: false}); forceStatusFilter(frame); forcePageFilter(frame); }, destroy(apiConfig, frame) { debug('destroy'); frame.options.destroyBy = { id: frame.options.id, type: 'post' }; defaultFormat(frame); defaultRelations(frame); }, copy(apiConfig, frame) { debug('copy'); defaultFormat(frame); defaultRelations(frame); } };