Skip to content

Commit

Permalink
fix(core): ensure against slug overwrite (#2139)
Browse files Browse the repository at this point in the history
  • Loading branch information
barthc authored and erquhart committed Apr 10, 2019
1 parent 14b6292 commit 0ce995d
Show file tree
Hide file tree
Showing 6 changed files with 94 additions and 7 deletions.
6 changes: 5 additions & 1 deletion packages/netlify-cms-core/src/actions/editorialWorkflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -334,6 +337,7 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
serializedEntryDraft,
assetProxies.toJS(),
state.integrations,
usedSlugs,
];

try {
Expand Down
12 changes: 10 additions & 2 deletions packages/netlify-cms-core/src/actions/entries.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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({
Expand Down
62 changes: 59 additions & 3 deletions packages/netlify-cms-core/src/backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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 = {
Expand All @@ -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 = {
Expand Down
10 changes: 10 additions & 0 deletions packages/netlify-cms-core/src/reducers/editorialWorkflow.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Map, List, fromJS } from 'immutable';
import { startsWith } from 'lodash';
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import {
UNPUBLISHED_ENTRY_REQUEST,
Expand Down Expand Up @@ -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;
5 changes: 4 additions & 1 deletion packages/netlify-cms-core/src/reducers/entries.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
};

Expand Down
6 changes: 6 additions & 0 deletions packages/netlify-cms-core/src/reducers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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);

Expand Down

0 comments on commit 0ce995d

Please sign in to comment.