fix(core): ensure against slug overwrite (#2139)

This commit is contained in:
Bartholomew 2019-04-10 21:38:53 +01:00 committed by Shawn Erquhart
parent 14b6292eab
commit 0ce995d78c
6 changed files with 94 additions and 7 deletions

View File

@ -3,7 +3,7 @@ import { actions as notifActions } from 'redux-notifications';
import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; import { BEGIN, COMMIT, REVERT } from 'redux-optimist';
import { serializeValues } from 'Lib/serializeEntryValues'; import { serializeValues } from 'Lib/serializeEntryValues';
import { currentBackend } from 'coreSrc/backend'; import { currentBackend } from 'coreSrc/backend';
import { getAsset } from 'Reducers'; import { getAsset, selectPublishedSlugs, selectUnpublishedSlugs } from 'Reducers';
import { selectFields } from 'Reducers/collections'; import { selectFields } from 'Reducers/collections';
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes'; import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import { EDITORIAL_WORKFLOW_ERROR } from 'netlify-cms-lib-util'; import { EDITORIAL_WORKFLOW_ERROR } from 'netlify-cms-lib-util';
@ -288,6 +288,9 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
const state = getState(); const state = getState();
const entryDraft = state.entryDraft; const entryDraft = state.entryDraft;
const fieldsErrors = entryDraft.get('fieldsErrors'); 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 // Early return if draft contains validation errors
if (!fieldsErrors.isEmpty()) { if (!fieldsErrors.isEmpty()) {
@ -334,6 +337,7 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
serializedEntryDraft, serializedEntryDraft,
assetProxies.toJS(), assetProxies.toJS(),
state.integrations, state.integrations,
usedSlugs,
]; ];
try { try {

View File

@ -3,7 +3,7 @@ import { actions as notifActions } from 'redux-notifications';
import { serializeValues } from 'Lib/serializeEntryValues'; import { serializeValues } from 'Lib/serializeEntryValues';
import { currentBackend } from 'coreSrc/backend'; import { currentBackend } from 'coreSrc/backend';
import { getIntegrationProvider } from 'Integrations'; import { getIntegrationProvider } from 'Integrations';
import { getAsset, selectIntegration } from 'Reducers'; import { getAsset, selectIntegration, selectPublishedSlugs } from 'Reducers';
import { selectFields } from 'Reducers/collections'; import { selectFields } from 'Reducers/collections';
import { selectCollectionEntriesCursor } from 'Reducers/cursors'; import { selectCollectionEntriesCursor } from 'Reducers/cursors';
import { Cursor } from 'netlify-cms-lib-util'; import { Cursor } from 'netlify-cms-lib-util';
@ -452,6 +452,7 @@ export function persistEntry(collection) {
const state = getState(); const state = getState();
const entryDraft = state.entryDraft; const entryDraft = state.entryDraft;
const fieldsErrors = entryDraft.get('fieldsErrors'); const fieldsErrors = entryDraft.get('fieldsErrors');
const usedSlugs = selectPublishedSlugs(state, collection.get('name'));
// Early return if draft contains validation errors // Early return if draft contains validation errors
if (!fieldsErrors.isEmpty()) { if (!fieldsErrors.isEmpty()) {
@ -488,7 +489,14 @@ export function persistEntry(collection) {
const serializedEntryDraft = entryDraft.set('entry', serializedEntry); const serializedEntryDraft = entryDraft.set('entry', serializedEntry);
dispatch(entryPersisting(collection, serializedEntry)); dispatch(entryPersisting(collection, serializedEntry));
return backend return backend
.persistEntry(state.config, collection, serializedEntryDraft, assetProxies.toJS()) .persistEntry(
state.config,
collection,
serializedEntryDraft,
assetProxies.toJS(),
state.integrations,
usedSlugs,
)
.then(slug => { .then(slug => {
dispatch( dispatch(
notifSend({ notifSend({

View File

@ -17,7 +17,12 @@ import {
import { createEntry } from 'ValueObjects/Entry'; import { createEntry } from 'ValueObjects/Entry';
import { sanitizeSlug } from 'Lib/urlHelper'; import { sanitizeSlug } from 'Lib/urlHelper';
import { getBackend } from 'Lib/registry'; 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 { EDITORIAL_WORKFLOW, status } from 'Constants/publishModes';
import { import {
SLUG_MISSING_REQUIRED_DATE, SLUG_MISSING_REQUIRED_DATE,
@ -256,6 +261,48 @@ class Backend {
getToken = () => this.implementation.getToken(); 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) { processEntries(loadedEntries, collection) {
const collectionFilter = collection.get('filter'); const collectionFilter = collection.get('filter');
const entries = loadedEntries.map(loadedEntry => 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 newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
const parsedData = { const parsedData = {
@ -582,10 +637,11 @@ class Backend {
if (!selectAllowNewEntries(collection)) { if (!selectAllowNewEntries(collection)) {
throw new Error('Not allowed to create new entries in this collection'); throw new Error('Not allowed to create new entries in this collection');
} }
const slug = slugFormatter( const slug = await this.generateUniqueSlug(
collection, collection,
entryDraft.getIn(['entry', 'data']), entryDraft.getIn(['entry', 'data']),
config.get('slug'), config.get('slug'),
usedSlugs,
); );
const path = selectEntryPath(collection, slug); const path = selectEntryPath(collection, slug);
entryObj = { entryObj = {

View File

@ -1,4 +1,5 @@
import { Map, List, fromJS } from 'immutable'; import { Map, List, fromJS } from 'immutable';
import { startsWith } from 'lodash';
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes'; import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import { import {
UNPUBLISHED_ENTRY_REQUEST, UNPUBLISHED_ENTRY_REQUEST,
@ -140,4 +141,13 @@ export const selectUnpublishedEntriesByStatus = (state, status) => {
.valueSeq(); .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; export default unpublishedEntries;

View File

@ -104,8 +104,11 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
export const selectEntry = (state, collection, slug) => export const selectEntry = (state, collection, slug) =>
state.getIn(['entities', `${collection}.${slug}`]); state.getIn(['entities', `${collection}.${slug}`]);
export const selectPublishedSlugs = (state, collection) =>
state.getIn(['pages', collection, 'ids'], List());
export const selectEntries = (state, collection) => { 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)); return slugs && slugs.map(slug => selectEntry(state, collection, slug));
}; };

View File

@ -39,6 +39,9 @@ export const selectEntry = (state, collection, slug) =>
export const selectEntries = (state, collection) => export const selectEntries = (state, collection) =>
fromEntries.selectEntries(state.entries, collection); fromEntries.selectEntries(state.entries, collection);
export const selectPublishedSlugs = (state, collection) =>
fromEntries.selectPublishedSlugs(state.entries, collection);
export const selectSearchedEntries = state => { export const selectSearchedEntries = state => {
const searchItems = state.search.get('entryIds'); const searchItems = state.search.get('entryIds');
return ( return (
@ -58,6 +61,9 @@ export const selectUnpublishedEntry = (state, collection, slug) =>
export const selectUnpublishedEntriesByStatus = (state, status) => export const selectUnpublishedEntriesByStatus = (state, status) =>
fromEditorialWorkflow.selectUnpublishedEntriesByStatus(state.editorialWorkflow, status); fromEditorialWorkflow.selectUnpublishedEntriesByStatus(state.editorialWorkflow, status);
export const selectUnpublishedSlugs = (state, collection) =>
fromEditorialWorkflow.selectUnpublishedSlugs(state.editorialWorkflow, collection);
export const selectIntegration = (state, collection, hook) => export const selectIntegration = (state, collection, hook) =>
fromIntegrations.selectIntegration(state.integrations, collection, hook); fromIntegrations.selectIntegration(state.integrations, collection, hook);