From 0ce995d78c8123a32a3e9ef7117fd5386f3cd02d Mon Sep 17 00:00:00 2001 From: Bartholomew Date: Wed, 10 Apr 2019 21:38:53 +0100 Subject: [PATCH] fix(core): ensure against slug overwrite (#2139) --- .../src/actions/editorialWorkflow.js | 6 +- .../netlify-cms-core/src/actions/entries.js | 12 +++- packages/netlify-cms-core/src/backend.js | 62 ++++++++++++++++++- .../src/reducers/editorialWorkflow.js | 10 +++ .../netlify-cms-core/src/reducers/entries.js | 5 +- .../netlify-cms-core/src/reducers/index.js | 6 ++ 6 files changed, 94 insertions(+), 7 deletions(-) diff --git a/packages/netlify-cms-core/src/actions/editorialWorkflow.js b/packages/netlify-cms-core/src/actions/editorialWorkflow.js index 664489a1..01504477 100644 --- a/packages/netlify-cms-core/src/actions/editorialWorkflow.js +++ b/packages/netlify-cms-core/src/actions/editorialWorkflow.js @@ -3,7 +3,7 @@ import { actions as notifActions } from 'redux-notifications'; import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; import { serializeValues } from 'Lib/serializeEntryValues'; import { currentBackend } from 'coreSrc/backend'; -import { getAsset } from 'Reducers'; +import { getAsset, selectPublishedSlugs, selectUnpublishedSlugs } from 'Reducers'; import { selectFields } from 'Reducers/collections'; import { EDITORIAL_WORKFLOW } from 'Constants/publishModes'; import { EDITORIAL_WORKFLOW_ERROR } from 'netlify-cms-lib-util'; @@ -288,6 +288,9 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) { const state = getState(); const entryDraft = state.entryDraft; const fieldsErrors = entryDraft.get('fieldsErrors'); + const unpublishedSlugs = selectUnpublishedSlugs(state, collection.get('name')); + const publishedSlugs = selectPublishedSlugs(state, collection.get('name')); + const usedSlugs = publishedSlugs.concat(unpublishedSlugs); // Early return if draft contains validation errors if (!fieldsErrors.isEmpty()) { @@ -334,6 +337,7 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) { serializedEntryDraft, assetProxies.toJS(), state.integrations, + usedSlugs, ]; try { diff --git a/packages/netlify-cms-core/src/actions/entries.js b/packages/netlify-cms-core/src/actions/entries.js index 9529ce25..cd88ff5c 100644 --- a/packages/netlify-cms-core/src/actions/entries.js +++ b/packages/netlify-cms-core/src/actions/entries.js @@ -3,7 +3,7 @@ import { actions as notifActions } from 'redux-notifications'; import { serializeValues } from 'Lib/serializeEntryValues'; import { currentBackend } from 'coreSrc/backend'; import { getIntegrationProvider } from 'Integrations'; -import { getAsset, selectIntegration } from 'Reducers'; +import { getAsset, selectIntegration, selectPublishedSlugs } from 'Reducers'; import { selectFields } from 'Reducers/collections'; import { selectCollectionEntriesCursor } from 'Reducers/cursors'; import { Cursor } from 'netlify-cms-lib-util'; @@ -452,6 +452,7 @@ export function persistEntry(collection) { const state = getState(); const entryDraft = state.entryDraft; const fieldsErrors = entryDraft.get('fieldsErrors'); + const usedSlugs = selectPublishedSlugs(state, collection.get('name')); // Early return if draft contains validation errors if (!fieldsErrors.isEmpty()) { @@ -488,7 +489,14 @@ export function persistEntry(collection) { const serializedEntryDraft = entryDraft.set('entry', serializedEntry); dispatch(entryPersisting(collection, serializedEntry)); return backend - .persistEntry(state.config, collection, serializedEntryDraft, assetProxies.toJS()) + .persistEntry( + state.config, + collection, + serializedEntryDraft, + assetProxies.toJS(), + state.integrations, + usedSlugs, + ) .then(slug => { dispatch( notifSend({ diff --git a/packages/netlify-cms-core/src/backend.js b/packages/netlify-cms-core/src/backend.js index 9652d7b5..6d569bec 100644 --- a/packages/netlify-cms-core/src/backend.js +++ b/packages/netlify-cms-core/src/backend.js @@ -17,7 +17,12 @@ import { import { createEntry } from 'ValueObjects/Entry'; import { sanitizeSlug } from 'Lib/urlHelper'; import { getBackend } from 'Lib/registry'; -import { localForage, Cursor, CURSOR_COMPATIBILITY_SYMBOL } from 'netlify-cms-lib-util'; +import { + localForage, + Cursor, + CURSOR_COMPATIBILITY_SYMBOL, + EditorialWorkflowError, +} from 'netlify-cms-lib-util'; import { EDITORIAL_WORKFLOW, status } from 'Constants/publishModes'; import { SLUG_MISSING_REQUIRED_DATE, @@ -256,6 +261,48 @@ class Backend { getToken = () => this.implementation.getToken(); + async entryExist(collection, path, slug) { + const unpublishedEntry = + this.implementation.unpublishedEntry && + (await this.implementation.unpublishedEntry(collection, slug).catch(error => { + if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) { + return Promise.resolve(false); + } + return Promise.reject(error); + })); + + if (unpublishedEntry) return unpublishedEntry; + + const publishedEntry = await this.implementation + .getEntry(collection, slug, path) + .then(({ data }) => data) + .catch(error => { + if (error.status === 404 || error.message.includes(404)) { + return Promise.resolve(false); + } + return Promise.reject(error); + }); + + return publishedEntry; + } + + async generateUniqueSlug(collection, entryData, slugConfig, usedSlugs) { + const slug = slugFormatter(collection, entryData, slugConfig); + const sanitizeEntrySlug = partialRight(sanitizeSlug, slugConfig); + let i = 1; + let sanitizedSlug = slug; + let uniqueSlug = sanitizedSlug; + + // Check for duplicate slug in loaded entities store first before repo + while ( + usedSlugs.includes(uniqueSlug) || + (await this.entryExist(collection, selectEntryPath(collection, uniqueSlug), uniqueSlug)) + ) { + uniqueSlug = sanitizeEntrySlug(`${sanitizedSlug} ${i++}`); + } + return uniqueSlug; + } + processEntries(loadedEntries, collection) { const collectionFilter = collection.get('filter'); const entries = loadedEntries.map(loadedEntry => @@ -569,7 +616,15 @@ class Backend { }; } - persistEntry(config, collection, entryDraft, MediaFiles, integrations, options = {}) { + async persistEntry( + config, + collection, + entryDraft, + MediaFiles, + integrations, + usedSlugs, + options = {}, + ) { const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; const parsedData = { @@ -582,10 +637,11 @@ class Backend { if (!selectAllowNewEntries(collection)) { throw new Error('Not allowed to create new entries in this collection'); } - const slug = slugFormatter( + const slug = await this.generateUniqueSlug( collection, entryDraft.getIn(['entry', 'data']), config.get('slug'), + usedSlugs, ); const path = selectEntryPath(collection, slug); entryObj = { diff --git a/packages/netlify-cms-core/src/reducers/editorialWorkflow.js b/packages/netlify-cms-core/src/reducers/editorialWorkflow.js index 3788d593..7d6cab1d 100644 --- a/packages/netlify-cms-core/src/reducers/editorialWorkflow.js +++ b/packages/netlify-cms-core/src/reducers/editorialWorkflow.js @@ -1,4 +1,5 @@ import { Map, List, fromJS } from 'immutable'; +import { startsWith } from 'lodash'; import { EDITORIAL_WORKFLOW } from 'Constants/publishModes'; import { UNPUBLISHED_ENTRY_REQUEST, @@ -140,4 +141,13 @@ export const selectUnpublishedEntriesByStatus = (state, status) => { .valueSeq(); }; +export const selectUnpublishedSlugs = (state, collection) => { + if (!state.get('entities')) return null; + return state + .get('entities') + .filter((v, k) => startsWith(k, `${collection}.`)) + .map(entry => entry.get('slug')) + .valueSeq(); +}; + export default unpublishedEntries; diff --git a/packages/netlify-cms-core/src/reducers/entries.js b/packages/netlify-cms-core/src/reducers/entries.js index 70bf2d2f..f5527823 100644 --- a/packages/netlify-cms-core/src/reducers/entries.js +++ b/packages/netlify-cms-core/src/reducers/entries.js @@ -104,8 +104,11 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => { export const selectEntry = (state, collection, slug) => state.getIn(['entities', `${collection}.${slug}`]); +export const selectPublishedSlugs = (state, collection) => + state.getIn(['pages', collection, 'ids'], List()); + export const selectEntries = (state, collection) => { - const slugs = state.getIn(['pages', collection, 'ids']); + const slugs = selectPublishedSlugs(state, collection); return slugs && slugs.map(slug => selectEntry(state, collection, slug)); }; diff --git a/packages/netlify-cms-core/src/reducers/index.js b/packages/netlify-cms-core/src/reducers/index.js index 50228f96..92bd35a8 100644 --- a/packages/netlify-cms-core/src/reducers/index.js +++ b/packages/netlify-cms-core/src/reducers/index.js @@ -39,6 +39,9 @@ export const selectEntry = (state, collection, slug) => export const selectEntries = (state, collection) => fromEntries.selectEntries(state.entries, collection); +export const selectPublishedSlugs = (state, collection) => + fromEntries.selectPublishedSlugs(state.entries, collection); + export const selectSearchedEntries = state => { const searchItems = state.search.get('entryIds'); return ( @@ -58,6 +61,9 @@ export const selectUnpublishedEntry = (state, collection, slug) => export const selectUnpublishedEntriesByStatus = (state, status) => fromEditorialWorkflow.selectUnpublishedEntriesByStatus(state.editorialWorkflow, status); +export const selectUnpublishedSlugs = (state, collection) => + fromEditorialWorkflow.selectUnpublishedSlugs(state.editorialWorkflow, collection); + export const selectIntegration = (state, collection, hook) => fromIntegrations.selectIntegration(state.integrations, collection, hook);