From 6162421f04e432bb81ef255bac93a6a6eaa7d680 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Thu, 5 Apr 2018 16:10:46 -0700 Subject: [PATCH 01/45] Create cursor definition and Redux structure --- src/actions/entries.js | 24 ++++++--- src/reducers/cursors.js | 27 ++++++++++ src/reducers/index.js | 2 + src/valueObjects/Cursor.js | 108 +++++++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 src/reducers/cursors.js create mode 100644 src/valueObjects/Cursor.js diff --git a/src/actions/entries.js b/src/actions/entries.js index a0aed54947a6..6d0f087e606b 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -5,8 +5,11 @@ import { currentBackend } from 'Backends/backend'; import { getIntegrationProvider } from 'Integrations'; import { getAsset, selectIntegration } from 'Reducers'; import { selectFields } from 'Reducers/collections'; +import { selectCollectionEntriesCursor } from 'Reducers/cursors'; +import Cursor from 'ValueObjects/Cursor'; import { createEntry } from 'ValueObjects/Entry'; import ValidationErrorTypes from 'Constants/validationErrorTypes'; +import isArray from 'lodash/isArray'; const { notifSend } = notifActions; @@ -80,14 +83,15 @@ export function entriesLoading(collection) { }; } -export function entriesLoaded(collection, entries, pagination) { +export function entriesLoaded(collection, entries, pagination, cursor) { return { type: ENTRIES_SUCCESS, payload: { collection: collection.get('name'), entries, page: pagination, - }, + cursor: Cursor.create(cursor), + } }; } @@ -248,10 +252,18 @@ export function loadEntries(collection, page = 0) { const integration = selectIntegration(state, collection.get('name'), 'listEntries'); const provider = integration ? getIntegrationProvider(state.integrations, backend.getToken, integration) : backend; dispatch(entriesLoading(collection)); - provider.listEntries(collection, page).then( - response => dispatch(entriesLoaded(collection, response.entries.reverse(), response.pagination)), - error => dispatch(entriesFailed(collection, error)) - ); + provider.listEntries(collection, page) + .then(response => console.log(response) || response) + .then(response => ({ ...response, cursor: new Cursor(response.cursor) })) + .then(response => dispatch(entriesLoaded(collection, response.entries.reverse(), response.pagination, response.cursor))) + .catch(err => { + dispatch(notifSend({ + message: `Failed to load entries: ${ err }`, + kind: 'danger', + dismissAfter: 8000, + })); + return Promise.reject(dispatch(entriesFailed(collection, err))); + }); }; } diff --git a/src/reducers/cursors.js b/src/reducers/cursors.js new file mode 100644 index 000000000000..525f53b21e17 --- /dev/null +++ b/src/reducers/cursors.js @@ -0,0 +1,27 @@ +import { fromJS, Map } from 'immutable'; +import Cursor from "ValueObjects/Cursor"; +import { + ENTRIES_SUCCESS, +} from 'Actions/entries'; + +// Since pagination can be used for a variety of views (collections +// and searches are the most common examples), we namespace cursors by +// their type before storing them in the state. +export const selectCollectionEntriesCursor = (state, collectionName) => + new Cursor(state.getIn(["cursorsByType", "collectionEntries", collectionName])); + +const cursors = (state = fromJS({ cursorsByType: { collectionEntries: {} } }), action) => { + switch (action.type) { + case ENTRIES_SUCCESS: { + return state.setIn( + ["cursorsByType", "collectionEntries", action.payload.collection], + Cursor.create(action.payload.cursor).store + ); + } + + default: + return state; + } +}; + +export default cursors; diff --git a/src/reducers/index.js b/src/reducers/index.js index 2dc4334868d1..3c2c4ba7f74b 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -2,6 +2,7 @@ import auth from './auth'; import config from './config'; import integrations, * as fromIntegrations from './integrations'; import entries, * as fromEntries from './entries'; +import cursors from './cursors'; import editorialWorkflow, * as fromEditorialWorkflow from './editorialWorkflow'; import entryDraft from './entryDraft'; import collections from './collections'; @@ -17,6 +18,7 @@ const reducers = { search, integrations, entries, + cursors, editorialWorkflow, entryDraft, mediaLibrary, diff --git a/src/valueObjects/Cursor.js b/src/valueObjects/Cursor.js new file mode 100644 index 000000000000..20b5e13fb1f0 --- /dev/null +++ b/src/valueObjects/Cursor.js @@ -0,0 +1,108 @@ +import { fromJS, Map, Set } from "immutable"; + +const jsToMap = obj => { + if (obj === undefined) { + return Map(); + } + const immutableObj = fromJS(obj); + if (!Map.isMap(immutableObj)) { + throw new Error("Object must be equivalent to a Map."); + } + return immutableObj; +}; + +const knownMetaKeys = Set(["index", "count", "pageSize", "pageCount"]); +const filterUnknownMetaKeys = meta => meta.filter((v, k) => knownMetaKeys.has(k)); + +/* + createCursorMap takes one of three signatures: + - () -> cursor with empty actions, data, and meta + - (cursorMap: ) -> cursor + - (actions: , data: , meta: ) -> cursor +*/ +const createCursorMap = (...args) => { + const { actions, data, meta } = args.length === 1 + ? jsToMap(args[0]).toObject() + : { actions: args[0], data: args[1], meta: args[2] }; + return Map({ + // actions are a Set, rather than a List, to ensure an efficient .has + actions: Set(actions), + + // data and meta are Maps + data: jsToMap(data), + meta: jsToMap(meta).update(filterUnknownMetaKeys), + }); +}; + +const hasAction = (cursorMap, action) => cursorMap.hasIn(["actions", action]); + +const getActionHandlers = (cursorMap, handler) => + cursorMap.get("actions", Set()).toMap().map(action => handler(action)); + +// The cursor logic is entirely functional, so this class simply +// provides a chainable interface +export default class Cursor { + static create(...args) { + return new Cursor(...args); + } + + constructor(...args) { + if (args[0] instanceof Cursor) { + return args[0]; + } + + this.store = createCursorMap(...args); + this.actions = this.store.get("actions"); + this.data = this.store.get("data"); + this.meta = this.store.get("meta"); + } + + updateStore(...args) { + return new Cursor(this.store.update(...args)); + } + updateInStore(...args) { + return new Cursor(this.store.updateIn(...args)); + } + + hasAction(action) { + return hasAction(this.store, action); + } + addAction(action) { + return this.updateStore("actions", actions => actions.add(action)); + } + removeAction(action) { + return this.updateStore("actions", actions => actions.delete(action)); + } + setActions(actions) { + return this.updateStore(store => store.set("actions", Set(actions))); + } + mergeActions(actions) { + return this.updateStore("actions", oldActions => oldActions.union(actions)); + } + getActionHandlers(handler) { + return getActionHandlers(this.store, handler); + } + + setData(data) { + return new Cursor(this.store.set("data", jsToMap(data))); + } + mergeData(data) { + return new Cursor(this.store.mergeIn(["data"], jsToMap(data))); + } + wrapData(data) { + return this.updateStore("data", oldData => jsToMap(data).set("wrapped_cursor_data", oldData)); + } + unwrapData() { + return [this.store.get("data").delete("wrapped_cursor_data"), this.updateStore("data", data => data.get("wrapped_cursor_data"))]; + } + clearData() { + return this.updateStore("data", data => Map()); + } + + setMeta(meta) { + return this.updateStore(store => store.set("meta", jsToMap(meta))); + } + mergeMeta(meta) { + return this.updateStore(store => store.update("meta", oldMeta => oldMeta.merge(jsToMap(meta)))) + } +} From f5d54fede8b8e973701967d5f84b2f369fc0586c Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Mon, 9 Apr 2018 16:27:23 -0700 Subject: [PATCH 02/45] Create backwards-compatible cursor symbol for backend interface --- src/backends/backend.js | 64 ++++++++++++++++++++++++++------------ src/valueObjects/Cursor.js | 7 +++++ 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/backends/backend.js b/src/backends/backend.js index cf1962bcf0a5..7e31656d382e 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -17,6 +17,7 @@ import TestRepoBackend from "./test-repo/implementation"; import GitHubBackend from "./github/implementation"; import GitGatewayBackend from "./git-gateway/implementation"; import { registerBackend, getBackend } from 'Lib/registry'; +import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from '../valueObjects/Cursor'; /** * Register internal backends @@ -153,30 +154,53 @@ class Backend { getToken = () => this.implementation.getToken(); + processEntries(loadedEntries, collection) { + const collectionFilter = collection.get('filter'); + const entries = loadedEntries.map(loadedEntry => createEntry( + collection.get("name"), + selectEntrySlug(collection, loadedEntry.file.path), + loadedEntry.file.path, + { raw: loadedEntry.data || '', label: loadedEntry.file.label } + )); + const formattedEntries = entries.map(this.entryWithFormat(collection)); + // If this collection has a "filter" property, filter entries accordingly + const filteredEntries = collectionFilter + ? this.filterEntries({ entries: formattedEntries }, collectionFilter) + : formattedEntries; + return filteredEntries; + } + + listEntries(collection) { const listMethod = this.implementation[selectListMethod(collection)]; const extension = selectFolderEntryExtension(collection); - const collectionFilter = collection.get('filter'); return listMethod.call(this.implementation, collection, extension) - .then(loadedEntries => ( - loadedEntries.map(loadedEntry => createEntry( - collection.get("name"), - selectEntrySlug(collection, loadedEntry.file.path), - loadedEntry.file.path, - { raw: loadedEntry.data || '', label: loadedEntry.file.label } - )) - )) - .then(entries => ( - { - entries: entries.map(this.entryWithFormat(collection)), - } - )) - // If this collection has a "filter" property, filter entries accordingly - .then(loadedCollection => ( - { - entries: collectionFilter ? this.filterEntries(loadedCollection, collectionFilter) : loadedCollection.entries - } - )); + .then(loadedEntries => ({ + entries: this.processEntries(loadedEntries, collection), + /* + Wrap cursors so we can tell which collection the cursor is + from. This is done to prevent traverseCursor from requiring a + `collection` argument. + */ + cursor: Cursor.create(loadedEntries[CURSOR_COMPATIBILITY_SYMBOL]).wrapData({ + cursorType: "collectionEntries", + collection, + }), + })); + } + + traverseCursor(cursor, action) { + const [data, unwrappedCursor] = cursor.unwrapData(); + // TODO: stop assuming all cursors are for collections + const collection = data.get("collection"); + return this.implementation.traverseCursor(unwrappedCursor, action) + .then(async ({ entries, cursor: newCursor }) => ({ + entries: this.processEntries(entries, collection), + cursor: Cursor.create(newCursor).wrapData({ + cursorType: "collectionEntries", + collection, + }), + })); } getEntry(collection, slug) { diff --git a/src/valueObjects/Cursor.js b/src/valueObjects/Cursor.js index 20b5e13fb1f0..84c5e1106c4a 100644 --- a/src/valueObjects/Cursor.js +++ b/src/valueObjects/Cursor.js @@ -106,3 +106,10 @@ export default class Cursor { return this.updateStore(store => store.update("meta", oldMeta => oldMeta.merge(jsToMap(meta)))) } } + +// This is a temporary hack to allow cursors to be added to the +// interface between backend.js and backends without modifying old +// backends at all. This should be removed in favor of wrapping old +// backends with a compatibility layer, as part of the backend API +// refactor. +export const CURSOR_COMPATIBILITY_SYMBOL = Symbol("cursor key for compatibility with old backends"); From e3ba975c72ff1ed4c869ee8b9584235b094e7421 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Wed, 18 Apr 2018 21:22:22 -0700 Subject: [PATCH 03/45] Make test-repo backend use cursor API --- example/index.html | 2 +- src/backends/test-repo/implementation.js | 68 +++++++++++++++++------- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/example/index.html b/example/index.html index ce8f6c4c6b21..40fab389695a 100644 --- a/example/index.html +++ b/example/index.html @@ -63,7 +63,7 @@ var ONE_DAY = 60 * 60 * 24 * 1000; - for (var i=1; i<=10; i++) { + for (var i=1; i<=20; i++) { var date = new Date(); date.setTime(date.getTime() + ONE_DAY); diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index e0f1e0d74d4e..945296b921fa 100644 --- a/src/backends/test-repo/implementation.js +++ b/src/backends/test-repo/implementation.js @@ -1,7 +1,9 @@ -import { remove, attempt, isError } from 'lodash'; +import { remove, attempt, isError, take } from 'lodash'; import uuid from 'uuid/v4'; +import { fromJS } from 'immutable'; import { EDITORIAL_WORKFLOW, status } from 'Constants/publishModes'; import { EditorialWorkflowError } from 'ValueObjects/errors'; +import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from 'ValueObjects/Cursor' import AuthenticationPage from './AuthenticationPage'; window.repoFiles = window.repoFiles || {}; @@ -16,6 +18,31 @@ function getFile(path) { return obj || {}; } +const pageSize = 10; + +const getCursor = (collection, extension, entries, index) => { + const count = entries.length; + const pageCount = Math.floor(count / pageSize); + return Cursor.create({ + actions: [ + ...(index < pageCount ? ["next", "last"] : []), + ...(index > 0 ? ["prev", "first"] : []), + ], + meta: { index, count, pageSize, pageCount }, + data: { collection, extension, index, pageCount }, + }); +}; + +const getFolderEntries = (folder, extension) => { + return Object.keys(window.repoFiles[folder]) + .filter(path => path.endsWith(`.${ extension }`)) + .map(path => ({ + file: { path: `${ folder }/${ path }` }, + data: window.repoFiles[folder][path].content, + })) + .reverse(); +}; + export default class TestRepo { constructor(config) { this.config = config; @@ -42,25 +69,28 @@ export default class TestRepo { return Promise.resolve(''); } + traverseCursor(cursor, action) { + const { collection, extension, index, pageCount } = cursor.data.toObject(); + const newIndex = (() => { + if (action === "next") { return index + 1; } + if (action === "prev") { return index - 1; } + if (action === "first") { return 0; } + if (action === "last") { return pageCount; } + })(); + // TODO: stop assuming cursors are for collections + const allEntries = getFolderEntries(collection.get('folder'), extension); + const entries = allEntries.slice(newIndex * pageSize, (newIndex * pageSize) + pageSize); + const newCursor = getCursor(collection, extension, allEntries, newIndex); + return Promise.resolve({ entries, cursor: newCursor }); + } + entriesByFolder(collection, extension) { - const entries = []; const folder = collection.get('folder'); - if (folder) { - for (const path in window.repoFiles[folder]) { - if (!path.endsWith('.' + extension)) { - continue; - } - - const file = { path: `${ folder }/${ path }` }; - entries.push( - { - file, - data: window.repoFiles[folder][path].content, - } - ); - } - } - return Promise.resolve(entries); + const entries = folder ? getFolderEntries(folder, extension) : []; + const cursor = getCursor(collection, extension, entries, 0); + const ret = take(entries, pageSize); + ret[CURSOR_COMPATIBILITY_SYMBOL] = cursor; + return Promise.resolve(ret); } entriesByFiles(collection) { @@ -101,7 +131,7 @@ export default class TestRepo { e.metaData.collection === collection && e.slug === slug )); unpubStore.splice(existingEntryIndex, 1); - return Promise.resolve() + return Promise.resolve(); } persistEntry({ path, raw, slug }, mediaFiles = [], options = {}) { From f93ab1c00687a0e11bacc96e175686428fef5c84 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Thu, 17 May 2018 15:32:45 -0700 Subject: [PATCH 04/45] Infinite pagination for cursors --- src/actions/entries.js | 88 +++++++++++++++++-- src/components/Collection/Entries/Entries.js | 10 ++- .../Collection/Entries/EntriesCollection.js | 34 ++++--- .../Collection/Entries/EntriesSearch.js | 23 +++-- .../Collection/Entries/EntryListing.js | 11 +-- src/reducers/entries.js | 6 +- src/reducers/search.js | 2 +- src/valueObjects/Cursor.js | 2 +- 8 files changed, 143 insertions(+), 33 deletions(-) diff --git a/src/actions/entries.js b/src/actions/entries.js index 6d0f087e606b..38896b4eaea5 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -1,4 +1,4 @@ -import { List } from 'immutable'; +import { fromJS, List, Set } from 'immutable'; import { actions as notifActions } from 'redux-notifications'; import { serializeValues } from 'Lib/serializeEntryValues'; import { currentBackend } from 'Backends/backend'; @@ -83,7 +83,7 @@ export function entriesLoading(collection) { }; } -export function entriesLoaded(collection, entries, pagination, cursor) { +export function entriesLoaded(collection, entries, pagination, cursor, append = true) { return { type: ENTRIES_SUCCESS, payload: { @@ -91,7 +91,8 @@ export function entriesLoaded(collection, entries, pagination, cursor) { entries, page: pagination, cursor: Cursor.create(cursor), - } + append, + }, }; } @@ -242,6 +243,16 @@ export function loadEntry(collection, slug) { }; } +const appendActions = fromJS({ + ["append_next"]: { action: "next", append: true }, +}); + +const addAppendActionsToCursor = cursor => Cursor + .create(cursor) + .updateStore("actions", actions => actions.union( + appendActions.filter(v => actions.has(v.get("action"))).keySeq() + )); + export function loadEntries(collection, page = 0) { return (dispatch, getState) => { if (collection.get('isFetching')) { @@ -251,11 +262,31 @@ export function loadEntries(collection, page = 0) { const backend = currentBackend(state.config); const integration = selectIntegration(state, collection.get('name'), 'listEntries'); const provider = integration ? getIntegrationProvider(state.integrations, backend.getToken, integration) : backend; + const append = !!(page && !isNaN(page) && page > 0); dispatch(entriesLoading(collection)); provider.listEntries(collection, page) - .then(response => console.log(response) || response) - .then(response => ({ ...response, cursor: new Cursor(response.cursor) })) - .then(response => dispatch(entriesLoaded(collection, response.entries.reverse(), response.pagination, response.cursor))) + .then(response => ({ + ...response, + + // The only existing backend using the pagination system is the + // Algolia integration, which is also the only integration used + // to list entries. Thus, this checking for an integration can + // determine whether or not this is using the old integer-based + // pagination API. Other backends will simply store an empty + // cursor, which behaves identically to no cursor at all. + cursor: integration + ? Cursor.create({ meta: { usingOldPaginationAPI: true }, data: { nextPage: page + 1 } }) + : Cursor.create(response.cursor), + })) + .then(response => dispatch(entriesLoaded( + collection, + response.cursor.meta.get('usingOldPaginationAPI') + ? response.entries.reverse() + : response.entries, + response.pagination, + addAppendActionsToCursor(response.cursor), + append, + ))) .catch(err => { dispatch(notifSend({ message: `Failed to load entries: ${ err }`, @@ -267,6 +298,51 @@ export function loadEntries(collection, page = 0) { }; } +function traverseCursor(backend, cursor, action) { + if (!cursor.actions.has(action)) { + throw new Error(`The current cursor does not support the pagination action "${ action }".`); + } + return backend.traverseCursor(cursor, action); +} + +export function traverseCollectionCursor(collection, action) { + return async (dispatch, getState) => { + const state = getState(); + if (state.entries.getIn(['pages', `${ collection.get('name') }`, 'isFetching',])) { + return; + } + const backend = currentBackend(state.config); + + const { action: realAction, append } = appendActions.has(action) + ? appendActions.get(action).toJS() + : { action, append: false }; + const cursor = selectCollectionEntriesCursor(state.cursors, collection.get('name')); + + // Handle cursors representing pages in the old, integer-based + // pagination API + if (cursor.meta.get("usingOldPaginationAPI", false)) { + const [data] = cursor.unwrapData(); + return loadEntries(collection, data.get("nextPage")); + } + + try { + dispatch(entriesLoading(collection)); + const { entries, cursor: newCursor } = await traverseCursor(backend, cursor, realAction); + // Pass null for the old pagination argument - this will + // eventually be removed. + return dispatch(entriesLoaded(collection, entries, null, addAppendActionsToCursor(newCursor), append)); + } catch (err) { + console.error(err); + dispatch(notifSend({ + message: `Failed to persist entry: ${ err }`, + kind: 'danger', + dismissAfter: 8000, + })); + return Promise.reject(dispatch(entriesFailed(collection, err))); + } + } +} + export function createEmptyDraft(collection) { return (dispatch) => { const dataFields = {}; diff --git a/src/components/Collection/Entries/Entries.js b/src/components/Collection/Entries/Entries.js index bc9974b2e33d..682d3956b134 100644 --- a/src/components/Collection/Entries/Entries.js +++ b/src/components/Collection/Entries/Entries.js @@ -11,7 +11,9 @@ const Entries = ({ page, onPaginate, isFetching, - viewStyle + viewStyle, + cursor, + handleCursorActions, }) => { const loadingMessages = [ 'Loading Entries', @@ -25,9 +27,9 @@ const Entries = ({ collections={collections} entries={entries} publicFolder={publicFolder} - page={page} - onPaginate={onPaginate} viewStyle={viewStyle} + cursor={cursor} + handleCursorActions={handleCursorActions} /> ); } @@ -46,6 +48,8 @@ Entries.propTypes = { page: PropTypes.number, isFetching: PropTypes.bool, viewStyle: PropTypes.string, + cursor: PropTypes.any.isRequired, + handleCursorActions: PropTypes.func.isRequired, }; export default Entries; diff --git a/src/components/Collection/Entries/EntriesCollection.js b/src/components/Collection/Entries/EntriesCollection.js index cf0c410bd027..da88a752dfcb 100644 --- a/src/components/Collection/Entries/EntriesCollection.js +++ b/src/components/Collection/Entries/EntriesCollection.js @@ -2,18 +2,26 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; -import { loadEntries as actionLoadEntries } from 'Actions/entries'; +import { partial } from 'lodash'; +import { + loadEntries as actionLoadEntries, + traverseCollectionCursor as actionTraverseCollectionCursor, +} from 'Actions/entries'; import { selectEntries } from 'Reducers'; +import { selectCollectionEntriesCursor } from 'Reducers/cursors'; +import Cursor from 'ValueObjects/Cursor'; import Entries from './Entries'; class EntriesCollection extends React.Component { static propTypes = { collection: ImmutablePropTypes.map.isRequired, publicFolder: PropTypes.string.isRequired, - page: PropTypes.number, entries: ImmutablePropTypes.list, isFetching: PropTypes.bool.isRequired, viewStyle: PropTypes.string, + cursor: PropTypes.object.isRequired, + loadEntries: PropTypes.func.isRequired, + traverseCollectionCursor: PropTypes.func.isRequired, }; componentDidMount() { @@ -30,31 +38,31 @@ class EntriesCollection extends React.Component { } } - handleLoadMore = page => { - const { collection, loadEntries } = this.props; - loadEntries(collection, page); - } + handleCursorActions = (cursor, action) => { + const { collection, traverseCollectionCursor } = this.props; + traverseCollectionCursor(collection, action); + }; render () { - const { collection, entries, publicFolder, page, isFetching, viewStyle } = this.props; + const { collection, entries, publicFolder, isFetching, viewStyle, cursor } = this.props; return ( ); } } function mapStateToProps(state, ownProps) { - const { name, collection, viewStyle } = ownProps; + const { collection, viewStyle } = ownProps; const { config } = state; const publicFolder = config.get('public_folder'); const page = state.entries.getIn(['pages', collection.get('name'), 'page']); @@ -62,11 +70,15 @@ function mapStateToProps(state, ownProps) { const entries = selectEntries(state, collection.get('name')); const isFetching = state.entries.getIn(['pages', collection.get('name'), 'isFetching'], false); - return { publicFolder, collection, page, entries, isFetching, viewStyle }; + const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get("name")); + const cursor = Cursor.create(rawCursor).clearData(); + + return { publicFolder, collection, page, entries, isFetching, viewStyle, cursor }; } const mapDispatchToProps = { loadEntries: actionLoadEntries, + traverseCollectionCursor: actionTraverseCollectionCursor, }; export default connect(mapStateToProps, mapDispatchToProps)(EntriesCollection); diff --git a/src/components/Collection/Entries/EntriesSearch.js b/src/components/Collection/Entries/EntriesSearch.js index daf58d006255..97d1d5990191 100644 --- a/src/components/Collection/Entries/EntriesSearch.js +++ b/src/components/Collection/Entries/EntriesSearch.js @@ -7,6 +7,7 @@ import { searchEntries as actionSearchEntries, clearSearch as actionClearSearch } from 'Actions/search'; +import Cursor from 'ValueObjects/Cursor'; import Entries from './Entries'; class EntriesSearch extends React.Component { @@ -36,15 +37,27 @@ class EntriesSearch extends React.Component { this.props.clearSearch(); } - handleLoadMore = (page) => { - const { searchTerm, searchEntries } = this.props; - if (!isNaN(page)) searchEntries(searchTerm, page); + getCursor = () => { + const { page } = this.props; + return Cursor.create({ + actions: isNaN(page) ? [] : ["append_next"], + }); + }; + + handleCursorActions = (action) => { + const { page, searchTerm, searchEntries } = this.props; + if (action === "append_next") { + const nextPage = page + 1; + searchEntries(searchTerm, nextPage); + } }; render () { const { collections, entries, publicFolder, page, isFetching } = this.props; return ( { - this.props.onPaginate(this.props.page + 1); + const { cursor, handleCursorActions } = this.props; + if (cursor.actions.has("append_next")) { + handleCursorActions("append_next"); + } }; inferFields = collection => { @@ -48,12 +49,12 @@ export default class EntryListing extends React.Component { const collectionLabel = collection.get('label'); const inferedFields = this.inferFields(collection); const entryCardProps = { collection, entry, inferedFields, publicFolder, key: idx, collectionLabel }; - return ; + return ; }); }; render() { - const { collections, entries, publicFolder } = this.props; + const { collections } = this.props; return (
diff --git a/src/reducers/entries.js b/src/reducers/entries.js index f6e39591de43..8670ac1ba19e 100644 --- a/src/reducers/entries.js +++ b/src/reducers/entries.js @@ -13,6 +13,7 @@ import { SEARCH_ENTRIES_SUCCESS } from 'Actions/search'; let collection; let loadedEntries; +let append; let page; const entries = (state = Map({ entities: Map(), pages: Map() }), action) => { @@ -32,6 +33,7 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => { case ENTRIES_SUCCESS: collection = action.payload.collection; loadedEntries = action.payload.entries; + append = action.payload.append; page = action.payload.page; return state.withMutations((map) => { loadedEntries.forEach(entry => ( @@ -41,7 +43,9 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => { const ids = List(loadedEntries.map(entry => entry.slug)); map.setIn(['pages', collection], Map({ page, - ids: (!page || page === 0) ? ids : map.getIn(['pages', collection, 'ids'], List()).concat(ids), + ids: append + ? map.getIn(['pages', collection, 'ids'], List()).concat(ids) + : ids, })); }); diff --git a/src/reducers/search.js b/src/reducers/search.js index db73abe85209..b5a31420661d 100644 --- a/src/reducers/search.js +++ b/src/reducers/search.js @@ -38,7 +38,7 @@ const entries = (state = defaultState, action) => { map.set('isFetching', false); map.set('page', page); map.set('term', searchTerm); - map.set('entryIds', page === 0 ? entryIds : map.get('entryIds', List()).concat(entryIds)); + map.set('entryIds', (!page || isNaN(page) || page === 0) ? entryIds : map.get('entryIds', List()).concat(entryIds)); }); case QUERY_REQUEST: diff --git a/src/valueObjects/Cursor.js b/src/valueObjects/Cursor.js index 84c5e1106c4a..3b9f926aaf8e 100644 --- a/src/valueObjects/Cursor.js +++ b/src/valueObjects/Cursor.js @@ -11,7 +11,7 @@ const jsToMap = obj => { return immutableObj; }; -const knownMetaKeys = Set(["index", "count", "pageSize", "pageCount"]); +const knownMetaKeys = Set(["index", "count", "pageSize", "pageCount", "usingOldPaginationAPI"]); const filterUnknownMetaKeys = meta => meta.filter((v, k) => knownMetaKeys.has(k)); /* From 0eefd9b1c7f0758778b5262142ba097d9897fe5a Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Thu, 27 Jul 2017 22:04:13 -0700 Subject: [PATCH 05/45] Add an optional `meta` field to `APIError` in `src/valueObjects/errors/APIError.js` --- src/valueObjects/errors/APIError.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/valueObjects/errors/APIError.js b/src/valueObjects/errors/APIError.js index 05db8b5cc4c0..fc45bea2d108 100644 --- a/src/valueObjects/errors/APIError.js +++ b/src/valueObjects/errors/APIError.js @@ -1,11 +1,12 @@ export const API_ERROR = 'API_ERROR'; export default class APIError extends Error { - constructor(message, status, api) { + constructor(message, status, api, meta={}) { super(message); this.message = message; this.status = status; this.api = api; this.name = API_ERROR; + this.meta = meta; } } From 01791136cb5bfb687afce9121a9718e42719f7cd Mon Sep 17 00:00:00 2001 From: Caleb Date: Fri, 4 Aug 2017 16:25:19 -0600 Subject: [PATCH 06/45] Initial GitLab commit. --- src/backends/backend.js | 2 + src/backends/gitlab/API.js | 162 +++++++++++++++++++++ src/backends/gitlab/AuthenticationPage.css | 12 ++ src/backends/gitlab/AuthenticationPage.js | 50 +++++++ src/backends/gitlab/implementation.js | 100 +++++++++++++ 5 files changed, 326 insertions(+) create mode 100644 src/backends/gitlab/API.js create mode 100644 src/backends/gitlab/AuthenticationPage.css create mode 100644 src/backends/gitlab/AuthenticationPage.js create mode 100644 src/backends/gitlab/implementation.js diff --git a/src/backends/backend.js b/src/backends/backend.js index 7e31656d382e..24454d0a03ee 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -15,6 +15,7 @@ import { createEntry } from "ValueObjects/Entry"; import { sanitizeSlug } from "Lib/urlHelper"; import TestRepoBackend from "./test-repo/implementation"; import GitHubBackend from "./github/implementation"; +import GitLabBackend from "./gitlab/implementation"; import GitGatewayBackend from "./git-gateway/implementation"; import { registerBackend, getBackend } from 'Lib/registry'; import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from '../valueObjects/Cursor'; @@ -24,6 +25,7 @@ import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from '../valueObjects/Cursor'; */ registerBackend('git-gateway', GitGatewayBackend); registerBackend('github', GitHubBackend); +registerBackend('gitlab', GitLabBackend); registerBackend('test-repo', TestRepoBackend); diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js new file mode 100644 index 000000000000..37f2dcad5a24 --- /dev/null +++ b/src/backends/gitlab/API.js @@ -0,0 +1,162 @@ +import LocalForage from "localforage"; +import { Base64 } from "js-base64"; +import { fill, isString } from "lodash"; +import AssetProxy from "../../valueObjects/AssetProxy"; +import { SIMPLE, EDITORIAL_WORKFLOW, status } from "../../constants/publishModes"; +import { APIError, EditorialWorkflowError } from "../../valueObjects/errors"; + +export default class API { + constructor(config) { + this.api_root = config.api_root || "https://gitlab.com/api/v4"; + this.token = config.token || false; + this.branch = config.branch || "master"; + this.repo = config.repo || ""; + this.repoURL = `/projects/${ encodeURIComponent(this.repo) }`; + } + + user() { + return this.request("/user"); + } + + /* TODO */ + isCollaborator(user) { + return this.request(`${ this.repoURL }/members/${ user.id }`) + .then(member =>(member.access_level >= 30)); + } + + requestHeaders(headers = {}) { + return { + ...headers, + ...(this.token ? { Authorization: `Bearer ${ this.token }` } : {}), + }; + } + + urlFor(path, options) { + const cacheBuster = `ts=${ new Date().getTime() }`; + const encodedParams = options.params + ? Object.entries(options.params).map( + ([key, val]) => `${ key }=${ encodeURIComponent(val) }`) + : []; + return this.api_root + path + `?${ [cacheBuster, ...encodedParams].join("&") }`; + } + + request(path, options = {}) { + const headers = this.requestHeaders(options.headers || {}); + const url = this.urlFor(path, options); + + return fetch(url, { ...options, headers }).then((response) => { + const contentType = response.headers.get("Content-Type"); + if (contentType && contentType.match(/json/)) { + return Promise.all([response, response.json()]); + } + return Promise.all([response, response.text()]); + }) + .catch(err => [err, null]) + .then(([response, value]) => (response.ok ? value : Promise.reject([value, response]))) + .catch(([errorValue, response]) => { + const message = (errorValue && errorValue.message) + ? errorValue.message + : (isString(errorValue) ? errorValue : ""); + throw new APIError(message, response && response.status, 'GitLab', { response, errorValue }); + }); + } + + readFile(path, sha, branch = this.branch) { + const cache = sha ? LocalForage.getItem(`gh.${ sha }`) : Promise.resolve(null); + return cache.then((cached) => { + if (cached) { return cached; } + + // Files must be downloaded from GitLab as base64 due to this bug: + // https://gitlab.com/gitlab-org/gitlab-ce/issues/31470 + return this.request(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }`, { + params: { ref: branch }, + cache: "no-store", + }).then(response => this.fromBase64(response.content)) + .then((result) => { + if (sha) { + LocalForage.setItem(`gh.${ sha }`, result); + } + return result; + }); + }); + } + + /* TODO */ + fileExists(path) { + return this.request(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }`, { + params: { ref: branch }, + cache: "no-store", + }).then; + } + + listFiles(path) { + return this.request(`${ this.repoURL }/repository/tree`, { + params: { path, ref: this.branch }, + }) + .then((files) => { + if (!Array.isArray(files)) { + throw new Error(`Cannot list files, path ${path} is not a directory but a ${files.type}`); + } + return files; + }) + .then(files => files.filter(file => file.type === "blob")); + } + + persistFiles(entry, mediaFiles, options) { + /* TODO */ + const newMedia = mediaFiles.filter(file => !file.uploaded); + + const mediaUploads = newMedia.map(file => this.uploadAndCommit(file, `${ options.commitMessage }: create ${ file.value }.`)); + + // Wait until media files are uploaded before we commit the main entry. + // This should help avoid inconsistent state. + return Promise.all(mediaUploads) + .then(() => this.uploadAndCommit({ ...entry, update: !options.newEntry }, options.commitMessage)); + } + + deleteFile(path, commit_message, options={}) { + const branch = options.branch || this.branch; + return this.request(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }`, { + method: "DELETE", + params: { commit_message, branch }, + }); + } + + toBase64(str) { + return Promise.resolve( + Base64.encode(str) + ); + } + + fromBase64(str) { + return Base64.decode(str); + } + + uploadAndCommit(item, commitMessage, branch = this.branch) { + const content = item instanceof AssetProxy ? item.toBase64() : this.toBase64(item.raw); + // Remove leading slash from path if exists. + const file_path = item.path.replace(/^\//, ''); + + // We cannot use the `/repository/files/:file_path` format here because the file content has to go + // in the URI as a parameter. This overloads the OPTIONS pre-request (at least in Chrome 61 beta). + return content.then(contentBase64 => this.request(`${ this.repoURL }/repository/commits`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + branch, + commit_message: commitMessage, + actions: [{ + action: (item.update ? "update" : "create"), + file_path, + content: contentBase64, + encoding: "base64", + }] + }), + })).then(response => Object.assign({}, item, { + sha: response.sha, + uploaded: true, + })); + } +} \ No newline at end of file diff --git a/src/backends/gitlab/AuthenticationPage.css b/src/backends/gitlab/AuthenticationPage.css new file mode 100644 index 000000000000..949d9b7dfc53 --- /dev/null +++ b/src/backends/gitlab/AuthenticationPage.css @@ -0,0 +1,12 @@ +.root { + display: flex; + flex-flow: column nowrap; + align-items: center; + justify-content: center; + height: 100vh; +} + +.button { + padding: .25em 1em; + height: auto; +} diff --git a/src/backends/gitlab/AuthenticationPage.js b/src/backends/gitlab/AuthenticationPage.js new file mode 100644 index 000000000000..036cf420fb88 --- /dev/null +++ b/src/backends/gitlab/AuthenticationPage.js @@ -0,0 +1,50 @@ +import React from 'react'; +import Button from 'react-toolbox/lib/button'; +import Authenticator from '../../lib/netlify-auth'; +import { Icon } from '../../components/UI'; +import { Notifs } from 'redux-notifications'; +import { Toast } from '../../components/UI/index'; +import styles from './AuthenticationPage.css'; + +export default class AuthenticationPage extends React.Component { + static propTypes = { + onLogin: React.PropTypes.func.isRequired, + }; + + state = {}; + + handleLogin = (e) => { + e.preventDefault(); + const cfg = { + base_url: this.props.base_url, + site_id: (document.location.host.split(':')[0] === 'localhost') ? 'cms.netlify.com' : this.props.siteId + }; + const auth = new Authenticator(cfg); + + auth.authenticate({ provider: 'gitlab', scope: 'repo' }, (err, data) => { + if (err) { + this.setState({ loginError: err.toString() }); + return; + } + this.props.onLogin(data); + }); + }; + + render() { + const { loginError } = this.state; + + return ( +
+ + {loginError &&

{loginError}

} + +
+ ); + } +} diff --git a/src/backends/gitlab/implementation.js b/src/backends/gitlab/implementation.js new file mode 100644 index 000000000000..4ab75ddea618 --- /dev/null +++ b/src/backends/gitlab/implementation.js @@ -0,0 +1,100 @@ +import semaphore from "semaphore"; +import AuthenticationPage from "./AuthenticationPage"; +import API from "./API"; +import { fileExtension } from '../../lib/pathHelper'; +import { EDITORIAL_WORKFLOW } from "../../constants/publishModes"; + +const MAX_CONCURRENT_DOWNLOADS = 10; + +export default class GitLab { + constructor(config, proxied = false) { + this.config = config; + + if (config.getIn(["publish_mode"]) === EDITORIAL_WORKFLOW) { + throw new Error("The GitLab backend does not support the Editorial Workflow.") + } + + if (!proxied && config.getIn(["backend", "repo"]) == null) { + throw new Error("The GitLab backend needs a \"repo\" in the backend configuration."); + } + + this.repo = config.getIn(["backend", "repo"], ""); + this.branch = config.getIn(["backend", "branch"], "master"); + this.api_root = config.getIn(["backend", "api_root"], "https://gitlab.com/api/v4"); + this.token = ''; + } + + authComponent() { + return AuthenticationPage; + } + + setUser(user) { + this.token = user.token; + this.api = new API({ token: this.token, branch: this.branch, repo: this.repo }); + } + + authenticate(state) { + this.token = state.token; + this.api = new API({ token: this.token, branch: this.branch, repo: this.repo, api_root: this.api_root }); + return this.api.user().then(user => + this.api.isCollaborator(user.login).then((isCollab) => { + // Unauthorized user + if (!isCollab) throw new Error("Your GitLab user account does not have access to this repo."); + // Authorized user + user.token = state.token; + return user; + }) + ); + } + + getToken() { + return Promise.resolve(this.token); + } + + entriesByFolder(collection, extension) { + return this.api.listFiles(collection.get("folder")) + .then(files => files.filter(file => fileExtension(file.name) === extension)) + .then(this.fetchFiles); + } + + entriesByFiles(collection) { + const files = collection.get("files").map(collectionFile => ({ + path: collectionFile.get("file"), + label: collectionFile.get("label"), + })); + return this.fetchFiles(files); + } + + fetchFiles = (files) => { + const sem = semaphore(MAX_CONCURRENT_DOWNLOADS); + const promises = []; + files.forEach((file) => { + promises.push(new Promise((resolve, reject) => ( + sem.take(() => this.api.readFile(file.path, file.sha).then((data) => { + resolve({ file, data }); + sem.leave(); + }).catch((err) => { + sem.leave(); + reject(err); + })) + ))); + }); + return Promise.all(promises); + }; + + // Fetches a single entry. + getEntry(collection, slug, path) { + return this.api.readFile(path).then(data => ({ + file: { path }, + data, + })); + } + + persistEntry(entry, mediaFiles = [], options = {}) { + return this.api.persistFiles(entry, mediaFiles, options); + } + + deleteFile(path, commitMessage, options) { + return this.api.deleteFile(path, commitMessage, options); + } +} From 3c984da158c6ee83370ae07504c0c62741729851 Mon Sep 17 00:00:00 2001 From: Caleb Date: Fri, 4 Aug 2017 18:07:11 -0600 Subject: [PATCH 07/45] Fix authentication/permission checking. --- src/backends/gitlab/API.js | 15 +++++++++++++-- src/backends/gitlab/implementation.js | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 37f2dcad5a24..8ff30789e401 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -18,10 +18,21 @@ export default class API { return this.request("/user"); } - /* TODO */ isCollaborator(user) { + const WRITE_ACCESS = 30; return this.request(`${ this.repoURL }/members/${ user.id }`) - .then(member =>(member.access_level >= 30)); + .then(member => (member.access_level >= WRITE_ACCESS)) + .catch((err) => { + // Member does not have any access. We cannot just check for 404, + // because a 404 is also returned if we have the wrong URI, + // just with an "error" key instead of a "message" key. + if (err.status === 404 && err.meta.errorValue["message"] === "404 Not found") { + return false; + } else { + // Otherwise, it is actually an API error. + throw err; + } + }); } requestHeaders(headers = {}) { diff --git a/src/backends/gitlab/implementation.js b/src/backends/gitlab/implementation.js index 4ab75ddea618..415c378012bf 100644 --- a/src/backends/gitlab/implementation.js +++ b/src/backends/gitlab/implementation.js @@ -37,7 +37,7 @@ export default class GitLab { this.token = state.token; this.api = new API({ token: this.token, branch: this.branch, repo: this.repo, api_root: this.api_root }); return this.api.user().then(user => - this.api.isCollaborator(user.login).then((isCollab) => { + this.api.isCollaborator(user).then((isCollab) => { // Unauthorized user if (!isCollab) throw new Error("Your GitLab user account does not have access to this repo."); // Authorized user From 53db61c11caa92ef9b978d429984c49de67844d7 Mon Sep 17 00:00:00 2001 From: Caleb Date: Mon, 7 Aug 2017 15:13:27 -0600 Subject: [PATCH 08/45] Make images with the same filename as already existing images overwrite them. This is not optimal, but it is how the GitHub implementation works and should be fixed later. --- src/backends/gitlab/API.js | 39 ++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 8ff30789e401..3b9b131aed63 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -57,13 +57,22 @@ export default class API { return fetch(url, { ...options, headers }).then((response) => { const contentType = response.headers.get("Content-Type"); + if (options.method === "HEAD") { + return Promise.all([response]); + } if (contentType && contentType.match(/json/)) { return Promise.all([response, response.json()]); } return Promise.all([response, response.text()]); }) .catch(err => [err, null]) - .then(([response, value]) => (response.ok ? value : Promise.reject([value, response]))) + .then(([response, value]) => { + if (!response.ok) return Promise.reject([value, response]); + /* TODO: remove magic. */ + if (value === undefined) return response; + /* OK */ + return value; + }) .catch(([errorValue, response]) => { const message = (errorValue && errorValue.message) ? errorValue.message @@ -92,12 +101,16 @@ export default class API { }); } - /* TODO */ - fileExists(path) { + fileExists(path, branch = this.branch) { return this.request(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }`, { + method: "HEAD", params: { ref: branch }, cache: "no-store", - }).then; + }).then(() => true).catch((err) => { + // TODO: 404 can mean either the file does not exist, or if an API + // endpoint doesn't exist. Is there a better way to check for this? + if (err.status === 404) {return false;} else {throw err;} + }); } listFiles(path) { @@ -114,15 +127,21 @@ export default class API { } persistFiles(entry, mediaFiles, options) { - /* TODO */ const newMedia = mediaFiles.filter(file => !file.uploaded); - - const mediaUploads = newMedia.map(file => this.uploadAndCommit(file, `${ options.commitMessage }: create ${ file.value }.`)); + const mediaUploads = newMedia.map(file => this.fileExists(file.path).then(exists => { + return this.uploadAndCommit(file, { + commitMessage: `${ options.commitMessage }: create ${ file.value }.`, + newFile: !exists + }); + })); // Wait until media files are uploaded before we commit the main entry. // This should help avoid inconsistent state. return Promise.all(mediaUploads) - .then(() => this.uploadAndCommit({ ...entry, update: !options.newEntry }, options.commitMessage)); + .then(() => this.uploadAndCommit(entry, { + commitMessage: options.commitMessage, + newFile: options.newEntry + })); } deleteFile(path, commit_message, options={}) { @@ -143,7 +162,7 @@ export default class API { return Base64.decode(str); } - uploadAndCommit(item, commitMessage, branch = this.branch) { + uploadAndCommit(item, {commitMessage, newFile = true, branch = this.branch}) { const content = item instanceof AssetProxy ? item.toBase64() : this.toBase64(item.raw); // Remove leading slash from path if exists. const file_path = item.path.replace(/^\//, ''); @@ -159,7 +178,7 @@ export default class API { branch, commit_message: commitMessage, actions: [{ - action: (item.update ? "update" : "create"), + action: (newFile ? "create" : "update"), file_path, content: contentBase64, encoding: "base64", From 503b61756708cb1eedfb836c5631e780438b8fe7 Mon Sep 17 00:00:00 2001 From: Caleb Date: Tue, 8 Aug 2017 09:22:33 -0600 Subject: [PATCH 09/45] Remove unneeded dependencies. --- src/backends/gitlab/API.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 3b9b131aed63..4c44d453ae14 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -1,9 +1,8 @@ import LocalForage from "localforage"; import { Base64 } from "js-base64"; -import { fill, isString } from "lodash"; +import { isString } from "lodash"; import AssetProxy from "../../valueObjects/AssetProxy"; -import { SIMPLE, EDITORIAL_WORKFLOW, status } from "../../constants/publishModes"; -import { APIError, EditorialWorkflowError } from "../../valueObjects/errors"; +import { APIError } from "../../valueObjects/errors"; export default class API { constructor(config) { From 7ad581438485dd974bca47fa25144d1d9d3c93d0 Mon Sep 17 00:00:00 2001 From: Caleb Date: Tue, 8 Aug 2017 13:34:20 -0600 Subject: [PATCH 10/45] Remove old GitHub code. --- src/backends/gitlab/API.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 4c44d453ae14..d4583289fba7 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -183,9 +183,6 @@ export default class API { encoding: "base64", }] }), - })).then(response => Object.assign({}, item, { - sha: response.sha, - uploaded: true, - })); + })).then(() => Object.assign({}, item, {uploaded: true})); } } \ No newline at end of file From a93ad46acb46d32654f8536c286da0d0abb9043d Mon Sep 17 00:00:00 2001 From: Caleb Date: Fri, 11 Aug 2017 15:02:34 -0600 Subject: [PATCH 11/45] Code cleanup from #508. --- src/backends/gitlab/API.js | 19 ++++++++----------- src/backends/gitlab/implementation.js | 3 +-- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index d4583289fba7..74607e386ae0 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -47,14 +47,14 @@ export default class API { ? Object.entries(options.params).map( ([key, val]) => `${ key }=${ encodeURIComponent(val) }`) : []; - return this.api_root + path + `?${ [cacheBuster, ...encodedParams].join("&") }`; + return `${ this.api_root }${ path }?${ [cacheBuster, ...encodedParams].join("&") }`; } request(path, options = {}) { const headers = this.requestHeaders(options.headers || {}); const url = this.urlFor(path, options); - - return fetch(url, { ...options, headers }).then((response) => { + return fetch(url, { ...options, headers }) + .then((response) => { const contentType = response.headers.get("Content-Type"); if (options.method === "HEAD") { return Promise.all([response]); @@ -73,10 +73,9 @@ export default class API { return value; }) .catch(([errorValue, response]) => { - const message = (errorValue && errorValue.message) - ? errorValue.message - : (isString(errorValue) ? errorValue : ""); - throw new APIError(message, response && response.status, 'GitLab', { response, errorValue }); + const errorMessageProp = (errorValue && errorValue.message) ? errorValue.message : null; + const message = errorMessageProp || (isString(errorValue) ? errorValue : ""); + throw new APIError(message, response && response.status, 'GitHub', { response, errorValue }); }); } @@ -152,9 +151,7 @@ export default class API { } toBase64(str) { - return Promise.resolve( - Base64.encode(str) - ); + return Promise.resolve(Base64.encode(str)); } fromBase64(str) { @@ -183,6 +180,6 @@ export default class API { encoding: "base64", }] }), - })).then(() => Object.assign({}, item, {uploaded: true})); + })).then(response => Object.assign({}, item, { uploaded: true })); } } \ No newline at end of file diff --git a/src/backends/gitlab/implementation.js b/src/backends/gitlab/implementation.js index 415c378012bf..0dce5eabe3ba 100644 --- a/src/backends/gitlab/implementation.js +++ b/src/backends/gitlab/implementation.js @@ -41,8 +41,7 @@ export default class GitLab { // Unauthorized user if (!isCollab) throw new Error("Your GitLab user account does not have access to this repo."); // Authorized user - user.token = state.token; - return user; + return Object.assign({}, user, { token: state.token }); }) ); } From 71258a5c97ec5cc63edf91b6f23451375325d342 Mon Sep 17 00:00:00 2001 From: Caleb Date: Mon, 14 Aug 2017 10:34:35 -0600 Subject: [PATCH 12/45] Clarify comment. --- src/backends/gitlab/API.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 74607e386ae0..32f580edc0c3 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -134,7 +134,7 @@ export default class API { })); // Wait until media files are uploaded before we commit the main entry. - // This should help avoid inconsistent state. + // This should help avoid inconsistent repository/website state. return Promise.all(mediaUploads) .then(() => this.uploadAndCommit(entry, { commitMessage: options.commitMessage, From e4764fbc1a3b9149bd061c51250efb2dda29365f Mon Sep 17 00:00:00 2001 From: Caleb Date: Wed, 16 Aug 2017 11:15:03 -0600 Subject: [PATCH 13/45] Fix permission checking for GitLab projects that are in groups/subgroups. --- src/backends/gitlab/API.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 32f580edc0c3..61f59037587b 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -17,10 +17,20 @@ export default class API { return this.request("/user"); } + isGroupProject() { + return this.request(this.repoURL).then(({ namespace }) => (namespace.kind === "group" ? `/groups/${ encodeURIComponent(namespace.full_path) }` : false)); + } + isCollaborator(user) { const WRITE_ACCESS = 30; - return this.request(`${ this.repoURL }/members/${ user.id }`) - .then(member => (member.access_level >= WRITE_ACCESS)) + return this.isGroupProject().then((group) => { + /* TODO: cleanup? */ + if (group === false) { + return this.request(`${ this.repoURL }/members/${ user.id }`); + } else { + return this.request(`${ group }/members/${ user.id }`); + } + }).then(member => (member.access_level >= WRITE_ACCESS)) .catch((err) => { // Member does not have any access. We cannot just check for 404, // because a 404 is also returned if we have the wrong URI, From 6c22e2e29205f36645a09d02bec9680a8782d696 Mon Sep 17 00:00:00 2001 From: Caleb Date: Tue, 29 Aug 2017 19:38:54 -0600 Subject: [PATCH 14/45] Cleanup `request` function and other minor cleanup. Thanks @Benaiah! --- src/backends/gitlab/API.js | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 61f59037587b..08c5a91808d1 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -18,20 +18,21 @@ export default class API { } isGroupProject() { - return this.request(this.repoURL).then(({ namespace }) => (namespace.kind === "group" ? `/groups/${ encodeURIComponent(namespace.full_path) }` : false)); + return this.request(this.repoURL) + .then(({ namespace }) => (namespace.kind === "group" ? `/groups/${ encodeURIComponent(namespace.full_path) }` : false)); } isCollaborator(user) { const WRITE_ACCESS = 30; return this.isGroupProject().then((group) => { - /* TODO: cleanup? */ if (group === false) { return this.request(`${ this.repoURL }/members/${ user.id }`); } else { return this.request(`${ group }/members/${ user.id }`); } - }).then(member => (member.access_level >= WRITE_ACCESS)) - .catch((err) => { + }) + .then(member => (member.access_level >= WRITE_ACCESS)) + .catch((err) => { // Member does not have any access. We cannot just check for 404, // because a 404 is also returned if we have the wrong URI, // just with an "error" key instead of a "message" key. @@ -41,7 +42,7 @@ export default class API { // Otherwise, it is actually an API error. throw err; } - }); + }); } requestHeaders(headers = {}) { @@ -74,18 +75,12 @@ export default class API { } return Promise.all([response, response.text()]); }) - .catch(err => [err, null]) - .then(([response, value]) => { - if (!response.ok) return Promise.reject([value, response]); - /* TODO: remove magic. */ - if (value === undefined) return response; - /* OK */ - return value; - }) + .catch(err => Promise.reject([err, null])) + .then(([response, value]) => (response.ok ? value : Promise.reject([value, response]))) .catch(([errorValue, response]) => { const errorMessageProp = (errorValue && errorValue.message) ? errorValue.message : null; const message = errorMessageProp || (isString(errorValue) ? errorValue : ""); - throw new APIError(message, response && response.status, 'GitHub', { response, errorValue }); + throw new APIError(message, response && response.status, 'GitLab', { response, errorValue }); }); } @@ -114,11 +109,12 @@ export default class API { method: "HEAD", params: { ref: branch }, cache: "no-store", - }).then(() => true).catch((err) => { - // TODO: 404 can mean either the file does not exist, or if an API - // endpoint doesn't exist. Is there a better way to check for this? - if (err.status === 404) {return false;} else {throw err;} - }); + }).then(() => true).catch(err => + // 404 can mean either the file does not exist, or if an API + // endpoint doesn't exist. We can't check this becaue we are + // not getting the content with a HEAD request. + (err.status === 404 ? false : Promise.reject(err)) + ); } listFiles(path) { From 98ea3e4d79131b8786e8282b792f119de271501e Mon Sep 17 00:00:00 2001 From: Shawn Erquhart Date: Thu, 4 Jan 2018 13:05:17 -0500 Subject: [PATCH 15/45] migrate GitLab backend to 1.0 --- src/backends/gitlab/API.js | 6 ++--- src/backends/gitlab/AuthenticationPage.css | 12 ---------- src/backends/gitlab/AuthenticationPage.js | 27 +++++++++++----------- src/backends/gitlab/implementation.js | 6 ++--- src/components/UI/Icon/images/_index.js | 2 ++ src/components/UI/Icon/images/gitlab.svg | 1 + 6 files changed, 22 insertions(+), 32 deletions(-) delete mode 100644 src/backends/gitlab/AuthenticationPage.css create mode 100644 src/components/UI/Icon/images/gitlab.svg diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 08c5a91808d1..36062bef5ddc 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -1,8 +1,8 @@ -import LocalForage from "localforage"; +import LocalForage from "Lib/LocalForage"; import { Base64 } from "js-base64"; import { isString } from "lodash"; -import AssetProxy from "../../valueObjects/AssetProxy"; -import { APIError } from "../../valueObjects/errors"; +import AssetProxy from "ValueObjects/AssetProxy"; +import { APIError } from "ValueObjects/errors"; export default class API { constructor(config) { diff --git a/src/backends/gitlab/AuthenticationPage.css b/src/backends/gitlab/AuthenticationPage.css deleted file mode 100644 index 949d9b7dfc53..000000000000 --- a/src/backends/gitlab/AuthenticationPage.css +++ /dev/null @@ -1,12 +0,0 @@ -.root { - display: flex; - flex-flow: column nowrap; - align-items: center; - justify-content: center; - height: 100vh; -} - -.button { - padding: .25em 1em; - height: auto; -} diff --git a/src/backends/gitlab/AuthenticationPage.js b/src/backends/gitlab/AuthenticationPage.js index 036cf420fb88..ef37fca9434f 100644 --- a/src/backends/gitlab/AuthenticationPage.js +++ b/src/backends/gitlab/AuthenticationPage.js @@ -1,14 +1,12 @@ +import PropTypes from 'prop-types'; import React from 'react'; -import Button from 'react-toolbox/lib/button'; -import Authenticator from '../../lib/netlify-auth'; -import { Icon } from '../../components/UI'; -import { Notifs } from 'redux-notifications'; -import { Toast } from '../../components/UI/index'; -import styles from './AuthenticationPage.css'; +import Authenticator from 'Lib/netlify-auth'; +import { Icon } from 'UI'; export default class AuthenticationPage extends React.Component { static propTypes = { - onLogin: React.PropTypes.func.isRequired, + onLogin: PropTypes.func.isRequired, + inProgress: PropTypes.bool, }; state = {}; @@ -32,18 +30,19 @@ export default class AuthenticationPage extends React.Component { render() { const { loginError } = this.state; + const { inProgress } = this.props; return ( -
- +
+ {loginError &&

{loginError}

} - + {inProgress ? "Logging in..." : "Login with GitLab"} +
); } diff --git a/src/backends/gitlab/implementation.js b/src/backends/gitlab/implementation.js index 0dce5eabe3ba..13a6f94d742d 100644 --- a/src/backends/gitlab/implementation.js +++ b/src/backends/gitlab/implementation.js @@ -1,15 +1,15 @@ import semaphore from "semaphore"; import AuthenticationPage from "./AuthenticationPage"; import API from "./API"; -import { fileExtension } from '../../lib/pathHelper'; -import { EDITORIAL_WORKFLOW } from "../../constants/publishModes"; +import { fileExtension } from 'Lib/pathHelper'; +import { EDITORIAL_WORKFLOW } from "Constants/publishModes"; const MAX_CONCURRENT_DOWNLOADS = 10; export default class GitLab { constructor(config, proxied = false) { this.config = config; - + if (config.getIn(["publish_mode"]) === EDITORIAL_WORKFLOW) { throw new Error("The GitLab backend does not support the Editorial Workflow.") } diff --git a/src/components/UI/Icon/images/_index.js b/src/components/UI/Icon/images/_index.js index f974d8736b06..352280f40bbe 100644 --- a/src/components/UI/Icon/images/_index.js +++ b/src/components/UI/Icon/images/_index.js @@ -13,6 +13,7 @@ import iconDragHandle from './drag-handle.svg'; import iconEye from './eye.svg'; import iconFolder from './folder.svg'; import iconGithub from './github.svg'; +import iconGitlab from './gitlab.svg'; import iconGrid from './grid.svg'; import iconH1 from './h1.svg'; import iconH2 from './h2.svg'; @@ -55,6 +56,7 @@ const images = { 'eye': iconEye, 'folder': iconFolder, 'github': iconGithub, + 'gitlab': iconGitlab, 'grid': iconGrid, 'h1': iconH1, 'h2': iconH2, diff --git a/src/components/UI/Icon/images/gitlab.svg b/src/components/UI/Icon/images/gitlab.svg new file mode 100644 index 000000000000..9d3134afcaf3 --- /dev/null +++ b/src/components/UI/Icon/images/gitlab.svg @@ -0,0 +1 @@ + \ No newline at end of file From f686a043f6c130aa0db09c6134322fa169b49796 Mon Sep 17 00:00:00 2001 From: Caleb Date: Thu, 4 Jan 2018 12:38:03 -0700 Subject: [PATCH 16/45] Download GitLab files as raw --- src/backends/gitlab/API.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 36062bef5ddc..7b039cdb550a 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -89,12 +89,10 @@ export default class API { return cache.then((cached) => { if (cached) { return cached; } - // Files must be downloaded from GitLab as base64 due to this bug: - // https://gitlab.com/gitlab-org/gitlab-ce/issues/31470 - return this.request(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }`, { + return this.request(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }/raw`, { params: { ref: branch }, cache: "no-store", - }).then(response => this.fromBase64(response.content)) + }) .then((result) => { if (sha) { LocalForage.setItem(`gh.${ sha }`, result); @@ -188,4 +186,4 @@ export default class API { }), })).then(response => Object.assign({}, item, { uploaded: true })); } -} \ No newline at end of file +} From b56f4c377f07767fb28ad2e54b36c928cf277444 Mon Sep 17 00:00:00 2001 From: Shawn Erquhart Date: Thu, 4 Jan 2018 16:14:58 -0500 Subject: [PATCH 17/45] add restoreUser method to GitLab backend --- src/backends/gitlab/implementation.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/backends/gitlab/implementation.js b/src/backends/gitlab/implementation.js index 13a6f94d742d..e3ec41dfd314 100644 --- a/src/backends/gitlab/implementation.js +++ b/src/backends/gitlab/implementation.js @@ -28,9 +28,8 @@ export default class GitLab { return AuthenticationPage; } - setUser(user) { - this.token = user.token; - this.api = new API({ token: this.token, branch: this.branch, repo: this.repo }); + restoreUser(user) { + return this.authenticate(user); } authenticate(state) { From b5da72ec7fb528e3c41767b2ade762a518ce6fe0 Mon Sep 17 00:00:00 2001 From: Caleb Date: Thu, 4 Jan 2018 14:31:41 -0700 Subject: [PATCH 18/45] update hasWriteAccess and logout for GitLab --- src/backends/gitlab/API.js | 2 +- src/backends/gitlab/implementation.js | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 7b039cdb550a..a591d0794814 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -22,7 +22,7 @@ export default class API { .then(({ namespace }) => (namespace.kind === "group" ? `/groups/${ encodeURIComponent(namespace.full_path) }` : false)); } - isCollaborator(user) { + hasWriteAccess(user) { const WRITE_ACCESS = 30; return this.isGroupProject().then((group) => { if (group === false) { diff --git a/src/backends/gitlab/implementation.js b/src/backends/gitlab/implementation.js index e3ec41dfd314..29cbbca08797 100644 --- a/src/backends/gitlab/implementation.js +++ b/src/backends/gitlab/implementation.js @@ -36,7 +36,7 @@ export default class GitLab { this.token = state.token; this.api = new API({ token: this.token, branch: this.branch, repo: this.repo, api_root: this.api_root }); return this.api.user().then(user => - this.api.isCollaborator(user).then((isCollab) => { + this.api.hasWriteAccess(user).then((isCollab) => { // Unauthorized user if (!isCollab) throw new Error("Your GitLab user account does not have access to this repo."); // Authorized user @@ -45,6 +45,11 @@ export default class GitLab { ); } + logout() { + this.token = null; + return; + } + getToken() { return Promise.resolve(this.token); } From b2f287c9ceb19802813089dc6aa188130d580a1e Mon Sep 17 00:00:00 2001 From: Caleb Date: Thu, 4 Jan 2018 16:18:03 -0700 Subject: [PATCH 19/45] add media library support for GitLab --- src/backends/gitlab/API.js | 29 ++++++++++++++++----------- src/backends/gitlab/implementation.js | 26 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index a591d0794814..2da8ab0ba594 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -67,7 +67,7 @@ export default class API { return fetch(url, { ...options, headers }) .then((response) => { const contentType = response.headers.get("Content-Type"); - if (options.method === "HEAD") { + if (options.method === "HEAD" || options.method === "DELETE") { return Promise.all([response]); } if (contentType && contentType.match(/json/)) { @@ -101,6 +101,12 @@ export default class API { }); }); } + + fileDownloadURL(path, branch = this.branch) { + return this.urlFor(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }/raw`, { + params: { ref: branch }, + }); + } fileExists(path, branch = this.branch) { return this.request(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }`, { @@ -119,12 +125,6 @@ export default class API { return this.request(`${ this.repoURL }/repository/tree`, { params: { path, ref: this.branch }, }) - .then((files) => { - if (!Array.isArray(files)) { - throw new Error(`Cannot list files, path ${path} is not a directory but a ${files.type}`); - } - return files; - }) .then(files => files.filter(file => file.type === "blob")); } @@ -132,7 +132,7 @@ export default class API { const newMedia = mediaFiles.filter(file => !file.uploaded); const mediaUploads = newMedia.map(file => this.fileExists(file.path).then(exists => { return this.uploadAndCommit(file, { - commitMessage: `${ options.commitMessage }: create ${ file.value }.`, + commitMessage: options.commitMessage, newFile: !exists }); })); @@ -140,10 +140,15 @@ export default class API { // Wait until media files are uploaded before we commit the main entry. // This should help avoid inconsistent repository/website state. return Promise.all(mediaUploads) - .then(() => this.uploadAndCommit(entry, { - commitMessage: options.commitMessage, - newFile: options.newEntry - })); + .then(mediaResponse => { + if (entry) { + return this.uploadAndCommit(entry, { + commitMessage: options.commitMessage, + newFile: options.newEntry + }); + } + return mediaUploads; + }); } deleteFile(path, commit_message, options={}) { diff --git a/src/backends/gitlab/implementation.js b/src/backends/gitlab/implementation.js index 29cbbca08797..bfd8a684a990 100644 --- a/src/backends/gitlab/implementation.js +++ b/src/backends/gitlab/implementation.js @@ -1,3 +1,4 @@ +import trimStart from 'lodash/trimStart'; import semaphore from "semaphore"; import AuthenticationPage from "./AuthenticationPage"; import API from "./API"; @@ -93,10 +94,35 @@ export default class GitLab { })); } + getMedia() { + return this.api.listFiles(this.config.get('media_folder')) + .then(files => files.map(({ id, name, path }) => { + const url = new URL(this.api.fileDownloadURL(path)); + if (url.pathname.match(/.svg$/)) { + url.search += (url.search.slice(1) === '' ? '?' : '&') + 'sanitize=true'; + } + return { id, name, url: url.href, path }; + })); + } + + persistEntry(entry, mediaFiles = [], options = {}) { return this.api.persistFiles(entry, mediaFiles, options); } + async persistMedia(mediaFile, options = {}) { + try { + const response = await this.api.persistFiles(null, [mediaFile], options); + const { value, size, path, fileObj } = mediaFile; + const url = this.api.fileDownloadURL(path); + return { name: value, size: fileObj.size, url, path: trimStart(path, '/') }; + } + catch (error) { + console.error(error); + throw error; + } + } + deleteFile(path, commitMessage, options) { return this.api.deleteFile(path, commitMessage, options); } From b348a327faf9d0c588d34e453e0025523dc94c6f Mon Sep 17 00:00:00 2001 From: Shawn Erquhart Date: Thu, 4 Jan 2018 18:55:37 -0500 Subject: [PATCH 20/45] fix gitlab collection caching --- src/backends/gitlab/implementation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backends/gitlab/implementation.js b/src/backends/gitlab/implementation.js index bfd8a684a990..e9d60d2b0415 100644 --- a/src/backends/gitlab/implementation.js +++ b/src/backends/gitlab/implementation.js @@ -74,7 +74,7 @@ export default class GitLab { const promises = []; files.forEach((file) => { promises.push(new Promise((resolve, reject) => ( - sem.take(() => this.api.readFile(file.path, file.sha).then((data) => { + sem.take(() => this.api.readFile(file.path, file.id).then((data) => { resolve({ file, data }); sem.leave(); }).catch((err) => { From 0d3092d37fa4a6e738c5f9894411dba57355cd67 Mon Sep 17 00:00:00 2001 From: Shawn Erquhart Date: Fri, 5 Jan 2018 10:48:37 -0500 Subject: [PATCH 21/45] remove old entry-plus-media persist logic --- src/backends/gitlab/API.js | 23 ++++++----------------- src/backends/gitlab/implementation.js | 18 ++++++------------ 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 2da8ab0ba594..d923722f24d2 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -128,27 +128,16 @@ export default class API { .then(files => files.filter(file => file.type === "blob")); } - persistFiles(entry, mediaFiles, options) { - const newMedia = mediaFiles.filter(file => !file.uploaded); - const mediaUploads = newMedia.map(file => this.fileExists(file.path).then(exists => { + persistFiles(files, options) { + const uploads = files.map(async file => { + const exists = await this.fileExists(file.path); return this.uploadAndCommit(file, { commitMessage: options.commitMessage, - newFile: !exists + newFile: !exists, }); - })); - - // Wait until media files are uploaded before we commit the main entry. - // This should help avoid inconsistent repository/website state. - return Promise.all(mediaUploads) - .then(mediaResponse => { - if (entry) { - return this.uploadAndCommit(entry, { - commitMessage: options.commitMessage, - newFile: options.newEntry - }); - } - return mediaUploads; }); + + return Promise.all(uploads) } deleteFile(path, commit_message, options={}) { diff --git a/src/backends/gitlab/implementation.js b/src/backends/gitlab/implementation.js index e9d60d2b0415..470d0a2d0b9b 100644 --- a/src/backends/gitlab/implementation.js +++ b/src/backends/gitlab/implementation.js @@ -106,21 +106,15 @@ export default class GitLab { } - persistEntry(entry, mediaFiles = [], options = {}) { - return this.api.persistFiles(entry, mediaFiles, options); + async persistEntry(entry, options = {}) { + return this.api.persistFiles([entry], options); } async persistMedia(mediaFile, options = {}) { - try { - const response = await this.api.persistFiles(null, [mediaFile], options); - const { value, size, path, fileObj } = mediaFile; - const url = this.api.fileDownloadURL(path); - return { name: value, size: fileObj.size, url, path: trimStart(path, '/') }; - } - catch (error) { - console.error(error); - throw error; - } + await this.api.persistFiles([mediaFile], options); + const { value, size, path, fileObj } = mediaFile; + const url = this.api.fileDownloadURL(path); + return { name: value, size: fileObj.size, url, path: trimStart(path, '/') }; } deleteFile(path, commitMessage, options) { From a5c3e823f58189e804eedd44a84473e2fff72ec2 Mon Sep 17 00:00:00 2001 From: Caleb Date: Mon, 8 Jan 2018 09:12:46 -0700 Subject: [PATCH 22/45] Fix access for GitLab group-owned repos. `hasWriteAccess` was not working in the case where permissions were granted on a single group-owned repo, instead of the entire group. --- src/backends/gitlab/API.js | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index d923722f24d2..e286c124db36 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -17,31 +17,17 @@ export default class API { return this.request("/user"); } - isGroupProject() { - return this.request(this.repoURL) - .then(({ namespace }) => (namespace.kind === "group" ? `/groups/${ encodeURIComponent(namespace.full_path) }` : false)); - } - hasWriteAccess(user) { const WRITE_ACCESS = 30; - return this.isGroupProject().then((group) => { - if (group === false) { - return this.request(`${ this.repoURL }/members/${ user.id }`); - } else { - return this.request(`${ group }/members/${ user.id }`); + return this.request(this.repoURL).then(({ permissions }) => { + const { project_access, group_access } = permissions; + if (project_access && (project_access.access_level >= WRITE_ACCESS)) { + return true; } - }) - .then(member => (member.access_level >= WRITE_ACCESS)) - .catch((err) => { - // Member does not have any access. We cannot just check for 404, - // because a 404 is also returned if we have the wrong URI, - // just with an "error" key instead of a "message" key. - if (err.status === 404 && err.meta.errorValue["message"] === "404 Not found") { - return false; - } else { - // Otherwise, it is actually an API error. - throw err; - } + if (group_access && (group_access.access_level >= WRITE_ACCESS)) { + return true; + } + return false; }); } From a13f17937d2711c70df2bfe2fcd01c7cdb4733e6 Mon Sep 17 00:00:00 2001 From: Caleb Date: Wed, 10 Jan 2018 15:29:46 -0700 Subject: [PATCH 23/45] simplify persistFiles --- src/backends/gitlab/API.js | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index e286c124db36..3483ebea0a94 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -93,19 +93,6 @@ export default class API { params: { ref: branch }, }); } - - fileExists(path, branch = this.branch) { - return this.request(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }`, { - method: "HEAD", - params: { ref: branch }, - cache: "no-store", - }).then(() => true).catch(err => - // 404 can mean either the file does not exist, or if an API - // endpoint doesn't exist. We can't check this becaue we are - // not getting the content with a HEAD request. - (err.status === 404 ? false : Promise.reject(err)) - ); - } listFiles(path) { return this.request(`${ this.repoURL }/repository/tree`, { @@ -115,15 +102,13 @@ export default class API { } persistFiles(files, options) { - const uploads = files.map(async file => { - const exists = await this.fileExists(file.path); - return this.uploadAndCommit(file, { - commitMessage: options.commitMessage, - newFile: !exists, - }); - }); + const uploadOpts = { + commitMessage: options.commitMessage, + updateFile: (options.newEntry === false) || false, + }; - return Promise.all(uploads) + const uploads = files.map(file => this.uploadAndCommit(file, uploadOpts)); + return Promise.all(uploads); } deleteFile(path, commit_message, options={}) { @@ -142,7 +127,7 @@ export default class API { return Base64.decode(str); } - uploadAndCommit(item, {commitMessage, newFile = true, branch = this.branch}) { + uploadAndCommit(item, {commitMessage, updateFile = false, branch = this.branch}) { const content = item instanceof AssetProxy ? item.toBase64() : this.toBase64(item.raw); // Remove leading slash from path if exists. const file_path = item.path.replace(/^\//, ''); @@ -158,7 +143,7 @@ export default class API { branch, commit_message: commitMessage, actions: [{ - action: (newFile ? "create" : "update"), + action: (updateFile ? "update" : "create"), file_path, content: contentBase64, encoding: "base64", From 9239cac1f03ea1fd73d3a1b0886b65319b37d405 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Thu, 19 Apr 2018 15:56:50 -0700 Subject: [PATCH 24/45] Fix auth scope --- src/backends/gitlab/AuthenticationPage.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backends/gitlab/AuthenticationPage.js b/src/backends/gitlab/AuthenticationPage.js index ef37fca9434f..cbadc87d6ee6 100644 --- a/src/backends/gitlab/AuthenticationPage.js +++ b/src/backends/gitlab/AuthenticationPage.js @@ -19,7 +19,7 @@ export default class AuthenticationPage extends React.Component { }; const auth = new Authenticator(cfg); - auth.authenticate({ provider: 'gitlab', scope: 'repo' }, (err, data) => { + auth.authenticate({ provider: 'gitlab', scope: 'api' }, (err, data) => { if (err) { this.setState({ loginError: err.toString() }); return; From d2bb871d1b694c603de6bc224af2fd5fe2de5cf0 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Thu, 19 Apr 2018 16:53:53 -0700 Subject: [PATCH 25/45] Implement cursor API for GitLab --- src/backends/gitlab/API.js | 107 +++++++++++++++++++------- src/backends/gitlab/implementation.js | 33 ++++++-- 2 files changed, 108 insertions(+), 32 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 3483ebea0a94..a52d753a3ffd 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -3,6 +3,7 @@ import { Base64 } from "js-base64"; import { isString } from "lodash"; import AssetProxy from "ValueObjects/AssetProxy"; import { APIError } from "ValueObjects/errors"; +import Cursor from "ValueObjects/Cursor" export default class API { constructor(config) { @@ -14,12 +15,12 @@ export default class API { } user() { - return this.request("/user"); + return this.request("/user").then(({ data }) => data); } hasWriteAccess(user) { const WRITE_ACCESS = 30; - return this.request(this.repoURL).then(({ permissions }) => { + return this.request(this.repoURL).then(({ data: { permissions } }) => { const { project_access, group_access } = permissions; if (project_access && (project_access.access_level >= WRITE_ACCESS)) { return true; @@ -47,11 +48,14 @@ export default class API { return `${ this.api_root }${ path }?${ [cacheBuster, ...encodedParams].join("&") }`; } - request(path, options = {}) { + requestRaw(path, options = {}) { const headers = this.requestHeaders(options.headers || {}); const url = this.urlFor(path, options); - return fetch(url, { ...options, headers }) - .then((response) => { + return fetch(url, { ...options, headers }); + } + + getResponseData(res, options = {}) { + return Promise.resolve(res).then((response) => { const contentType = response.headers.get("Content-Type"); if (options.method === "HEAD" || options.method === "DELETE") { return Promise.all([response]); @@ -61,25 +65,29 @@ export default class API { } return Promise.all([response, response.text()]); }) - .catch(err => Promise.reject([err, null])) - .then(([response, value]) => (response.ok ? value : Promise.reject([value, response]))) - .catch(([errorValue, response]) => { - const errorMessageProp = (errorValue && errorValue.message) ? errorValue.message : null; - const message = errorMessageProp || (isString(errorValue) ? errorValue : ""); - throw new APIError(message, response && response.status, 'GitLab', { response, errorValue }); - }); + .catch(err => Promise.reject([err, null])) + .then(([response, data]) => (response.ok ? { data, response } : Promise.reject([data, response]))) + .catch(([errorValue, response]) => { + const errorMessageProp = (errorValue && errorValue.message) ? errorValue.message : null; + const message = errorMessageProp || (isString(errorValue) ? errorValue : ""); + throw new APIError(message, response && response.status, 'GitLab', { response, errorValue }); + }); } - + + request(path, options = {}) { + return this.requestRaw(path, options).then(res => this.getResponseData(res, options)); + } + readFile(path, sha, branch = this.branch) { const cache = sha ? LocalForage.getItem(`gh.${ sha }`) : Promise.resolve(null); return cache.then((cached) => { if (cached) { return cached; } - + return this.request(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }/raw`, { params: { ref: branch }, cache: "no-store", }) - .then((result) => { + .then(({ data: result }) => { if (sha) { LocalForage.setItem(`gh.${ sha }`, result); } @@ -89,8 +97,51 @@ export default class API { } fileDownloadURL(path, branch = this.branch) { - return this.urlFor(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }/raw`, { - params: { ref: branch }, + return this.urlFor(`${this.repoURL}/repository/files/${encodeURIComponent(path)}/raw`, { + params: { ref: branch }, + }); + } + + // TODO: parse links into objects so we can update the token if it + // expires + getCursor(response) { + // indices and page counts are assumed to be zero-based, but the + // indices and page counts returned from GitLab are one-based + const index = parseInt(response.headers.get("X-Page"), 10) - 1; + const pageCount = parseInt(response.headers.get("X-Total-Pages"), 10) - 1; + const pageSize = parseInt(response.headers.get("X-Per-Page"), 10); + const count = parseInt(response.headers.get("X-Total"), 10); + const linksRaw = response.headers.get("Link"); + const links = linksRaw.split(",") + .map(str => str.trim().split(";")) + .map(([linkStr, keyStr]) => [linkStr.trim().match(/<(.*?)>/)[1], keyStr.match(/rel="(.*?)"/)[1]]) + .reduce((acc, [link, key]) => Object.assign(acc, { [key]: link }), {}); + return Cursor.create({ + actions: [ + ...((links.prev && index > 0) ? ["prev"] : []), + ...((links.next && index < pageCount) ? ["next"] : []), + ...((links.first && index > 0) ? ["first"] : []), + ...((links.last && index < pageCount) ? ["last"] : []), + ], + meta: { + index, + count, + pageSize, + pageCount, + }, + data: { + links, + }, + }); + } + + traverseCursor(cursor, action) { + const link = cursor.data.getIn(["links", action]); + return fetch(link, { headers: this.requestHeaders() }) + .then(res => { + const newCursor = this.getCursor(res); + return this.getResponseData(res) + .then(responseData => ({ entries: responseData.data, cursor: newCursor })); }); } @@ -98,7 +149,11 @@ export default class API { return this.request(`${ this.repoURL }/repository/tree`, { params: { path, ref: this.branch }, }) - .then(files => files.filter(file => file.type === "blob")); + .then(({ response, data }) => { + const files = data.filter(file => file.type === "blob"); + const cursor = this.getCursor(response); + return { files, cursor }; + }); } persistFiles(files, options) { @@ -111,12 +166,12 @@ export default class API { return Promise.all(uploads); } - deleteFile(path, commit_message, options={}) { + deleteFile(path, commit_message, options = {}) { const branch = options.branch || this.branch; - return this.request(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }`, { + return this.request(`${this.repoURL}/repository/files/${encodeURIComponent(path)}`, { method: "DELETE", params: { commit_message, branch }, - }); + }).then(({ data }) => data); } toBase64(str) { @@ -127,14 +182,14 @@ export default class API { return Base64.decode(str); } - uploadAndCommit(item, {commitMessage, updateFile = false, branch = this.branch}) { + uploadAndCommit(item, { commitMessage, updateFile = false, branch = this.branch }) { const content = item instanceof AssetProxy ? item.toBase64() : this.toBase64(item.raw); // Remove leading slash from path if exists. const file_path = item.path.replace(/^\//, ''); - + // We cannot use the `/repository/files/:file_path` format here because the file content has to go // in the URI as a parameter. This overloads the OPTIONS pre-request (at least in Chrome 61 beta). - return content.then(contentBase64 => this.request(`${ this.repoURL }/repository/commits`, { + return content.then(contentBase64 => this.request(`${this.repoURL}/repository/commits`, { method: "POST", headers: { "Content-Type": "application/json", @@ -147,8 +202,8 @@ export default class API { file_path, content: contentBase64, encoding: "base64", - }] + }], }), - })).then(response => Object.assign({}, item, { uploaded: true })); + })).then(() => Object.assign({}, item, { uploaded: true })); } } diff --git a/src/backends/gitlab/implementation.js b/src/backends/gitlab/implementation.js index 470d0a2d0b9b..337e567346a8 100644 --- a/src/backends/gitlab/implementation.js +++ b/src/backends/gitlab/implementation.js @@ -3,6 +3,7 @@ import semaphore from "semaphore"; import AuthenticationPage from "./AuthenticationPage"; import API from "./API"; import { fileExtension } from 'Lib/pathHelper'; +import { CURSOR_COMPATIBILITY_SYMBOL } from 'ValueObjects/Cursor'; import { EDITORIAL_WORKFLOW } from "Constants/publishModes"; const MAX_CONCURRENT_DOWNLOADS = 10; @@ -57,8 +58,14 @@ export default class GitLab { entriesByFolder(collection, extension) { return this.api.listFiles(collection.get("folder")) - .then(files => files.filter(file => fileExtension(file.name) === extension)) - .then(this.fetchFiles); + .then(({ files, cursor }) => + this.fetchFiles(files.filter(file => fileExtension(file.name) === extension)) + .then(fetchedFiles => { + const returnedFiles = fetchedFiles; + returnedFiles[CURSOR_COMPATIBILITY_SYMBOL] = cursor; + return returnedFiles; + }) + ); } entriesByFiles(collection) { @@ -66,7 +73,10 @@ export default class GitLab { path: collectionFile.get("file"), label: collectionFile.get("label"), })); - return this.fetchFiles(files); + return this.fetchFiles(files).then(fetchedFiles => { + const returnedFiles = fetchedFiles; + return returnedFiles; + }); } fetchFiles = (files) => { @@ -95,8 +105,11 @@ export default class GitLab { } getMedia() { + // TODO: list the entire folder to get all media files instead of + // just the first page, or implement cursor UI for the media + // library. return this.api.listFiles(this.config.get('media_folder')) - .then(files => files.map(({ id, name, path }) => { + .then(({ files }) => files.map(({ id, name, path }) => { const url = new URL(this.api.fileDownloadURL(path)); if (url.pathname.match(/.svg$/)) { url.search += (url.search.slice(1) === '' ? '?' : '&') + 'sanitize=true'; @@ -106,13 +119,13 @@ export default class GitLab { } - async persistEntry(entry, options = {}) { + async persistEntry(entry, mediaFiles, options = {}) { return this.api.persistFiles([entry], options); } async persistMedia(mediaFile, options = {}) { await this.api.persistFiles([mediaFile], options); - const { value, size, path, fileObj } = mediaFile; + const { value, path, fileObj } = mediaFile; const url = this.api.fileDownloadURL(path); return { name: value, size: fileObj.size, url, path: trimStart(path, '/') }; } @@ -120,4 +133,12 @@ export default class GitLab { deleteFile(path, commitMessage, options) { return this.api.deleteFile(path, commitMessage, options); } + + traverseCursor(cursor, action) { + return this.api.traverseCursor(cursor, action) + .then(async ({ entries, cursor: newCursor }) => ({ + entries: await Promise.all(entries.map(file => this.api.readFile(file.path, file.id).then(data => ({ file, data })))), + cursor: newCursor, + })); + } } From 086eb6942b91633b03a728077cba46bfd61f0e43 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Wed, 25 Apr 2018 01:40:52 -0700 Subject: [PATCH 26/45] Refactor GitLab backend with unsentRequest and reverse pagination --- src/backends/gitlab/API.js | 326 ++++++++---------- .../Collection/Entries/EntryListing.js | 3 +- src/lib/promiseHelper.js | 2 + src/lib/unsentRequest.js | 75 ++++ 4 files changed, 227 insertions(+), 179 deletions(-) create mode 100644 src/lib/unsentRequest.js diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index a52d753a3ffd..390700c32fb1 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -1,6 +1,9 @@ import LocalForage from "Lib/LocalForage"; import { Base64 } from "js-base64"; -import { isString } from "lodash"; +import { fromJS, List, Map } from "immutable"; +import { cond, flow, isString, partial, partialRight, omit, set, update } from "lodash"; +import unsentRequest from "Lib/unsentRequest"; +import { then } from "Lib/promiseHelper"; import AssetProxy from "ValueObjects/AssetProxy"; import { APIError } from "ValueObjects/errors"; import Cursor from "ValueObjects/Cursor" @@ -14,196 +17,163 @@ export default class API { this.repoURL = `/projects/${ encodeURIComponent(this.repo) }`; } - user() { - return this.request("/user").then(({ data }) => data); - } - - hasWriteAccess(user) { - const WRITE_ACCESS = 30; - return this.request(this.repoURL).then(({ data: { permissions } }) => { - const { project_access, group_access } = permissions; - if (project_access && (project_access.access_level >= WRITE_ACCESS)) { - return true; - } - if (group_access && (group_access.access_level >= WRITE_ACCESS)) { - return true; - } - return false; + withAuthorizationHeaders = req => + unsentRequest.withHeaders(this.token ? { Authorization: `Bearer ${ this.token }` } : {}, req); + + buildRequest = req => flow([ + unsentRequest.withRoot(this.api_root), + this.withAuthorizationHeaders, + unsentRequest.withTimestamp, + ])(req); + + request = async req => flow([this.buildRequest, unsentRequest.performRequest])(req); + requestURL = url => flow([unsentRequest.fromURL, this.request])(url); + responseToJSON = res => res.json() + responseToText = res => res.text() + requestJSON = req => this.request(req).then(this.responseToJSON); + requestText = req => this.request(req).then(this.responseToText); + requestJSONFromURL = url => this.requestURL(url).then(this.responseToJSON); + requestTextFromURL = url => this.requestURL(url).then(this.responseToText); + + user = () => this.requestJSONFromURL("/user"); + + WRITE_ACCESS = 30; + hasWriteAccess = user => this.requestJSONFromURL(this.repoURL).then(({ permissions }) => { + const { project_access, group_access } = permissions; + if (project_access && (project_access.access_level >= this.WRITE_ACCESS)) { + return true; + } + if (group_access && (group_access.access_level >= this.WRITE_ACCESS)) { + return true; + } + return false; + }); + + readFile = async (path, sha, ref=this.branch) => { + const cachedFile = sha ? await LocalForage.getItem(`gl.${ sha }`) : null; + if (cachedFile) { return cachedFile; } + const result = await this.requestText({ + url: `${ this.repoURL }/repository/files/${ encodeURIComponent(path) }/raw`, + params: { ref }, + cache: "no-store", }); - } - - requestHeaders(headers = {}) { - return { - ...headers, - ...(this.token ? { Authorization: `Bearer ${ this.token }` } : {}), - }; - } + if (sha) { LocalForage.setItem(`gl.${ sha }`, result) } + return result; + }; - urlFor(path, options) { - const cacheBuster = `ts=${ new Date().getTime() }`; - const encodedParams = options.params - ? Object.entries(options.params).map( - ([key, val]) => `${ key }=${ encodeURIComponent(val) }`) - : []; - return `${ this.api_root }${ path }?${ [cacheBuster, ...encodedParams].join("&") }`; - } - - requestRaw(path, options = {}) { - const headers = this.requestHeaders(options.headers || {}); - const url = this.urlFor(path, options); - return fetch(url, { ...options, headers }); - } - - getResponseData(res, options = {}) { - return Promise.resolve(res).then((response) => { - const contentType = response.headers.get("Content-Type"); - if (options.method === "HEAD" || options.method === "DELETE") { - return Promise.all([response]); - } - if (contentType && contentType.match(/json/)) { - return Promise.all([response, response.json()]); - } - return Promise.all([response, response.text()]); - }) - .catch(err => Promise.reject([err, null])) - .then(([response, data]) => (response.ok ? { data, response } : Promise.reject([data, response]))) - .catch(([errorValue, response]) => { - const errorMessageProp = (errorValue && errorValue.message) ? errorValue.message : null; - const message = errorMessageProp || (isString(errorValue) ? errorValue : ""); - throw new APIError(message, response && response.status, 'GitLab', { response, errorValue }); - }); - } + fileDownloadURL = (path, ref=this.branch) => unsentRequest.toURL(this.buildRequest({ + url: `${ this.repoURL }/repository/files/${ encodeURIComponent(path) }/raw`, + params: { ref }, + })); - request(path, options = {}) { - return this.requestRaw(path, options).then(res => this.getResponseData(res, options)); - } - - readFile(path, sha, branch = this.branch) { - const cache = sha ? LocalForage.getItem(`gh.${ sha }`) : Promise.resolve(null); - return cache.then((cached) => { - if (cached) { return cached; } - - return this.request(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }/raw`, { - params: { ref: branch }, - cache: "no-store", - }) - .then(({ data: result }) => { - if (sha) { - LocalForage.setItem(`gh.${ sha }`, result); - } - return result; - }); - }); - } - - fileDownloadURL(path, branch = this.branch) { - return this.urlFor(`${this.repoURL}/repository/files/${encodeURIComponent(path)}/raw`, { - params: { ref: branch }, - }); - } - - // TODO: parse links into objects so we can update the token if it - // expires - getCursor(response) { + getCursorFromHeaders = headers => { // indices and page counts are assumed to be zero-based, but the // indices and page counts returned from GitLab are one-based - const index = parseInt(response.headers.get("X-Page"), 10) - 1; - const pageCount = parseInt(response.headers.get("X-Total-Pages"), 10) - 1; - const pageSize = parseInt(response.headers.get("X-Per-Page"), 10); - const count = parseInt(response.headers.get("X-Total"), 10); - const linksRaw = response.headers.get("Link"); - const links = linksRaw.split(",") + const index = parseInt(headers.get("X-Page"), 10) - 1; + const pageCount = parseInt(headers.get("X-Total-Pages"), 10) - 1; + const pageSize = parseInt(headers.get("X-Per-Page"), 10); + const count = parseInt(headers.get("X-Total"), 10); + const linksRaw = headers.get("Link"); + const links = List(linksRaw.split(",")) .map(str => str.trim().split(";")) - .map(([linkStr, keyStr]) => [linkStr.trim().match(/<(.*?)>/)[1], keyStr.match(/rel="(.*?)"/)[1]]) - .reduce((acc, [link, key]) => Object.assign(acc, { [key]: link }), {}); + .map(([linkStr, keyStr]) => [ + keyStr.match(/rel="(.*?)"/)[1], + unsentRequest.fromURL(linkStr.trim().match(/<(.*?)>/)[1]), + ]) + .update(list => Map(list)); + const actions = links.keySeq().flatMap(key => ( + (key === "prev" && index > 0) || + (key === "next" && index < pageCount) || + (key === "first" && index > 0) || + (key === "last" && index < pageCount) + ) ? [key] : []); return Cursor.create({ - actions: [ - ...((links.prev && index > 0) ? ["prev"] : []), - ...((links.next && index < pageCount) ? ["next"] : []), - ...((links.first && index > 0) ? ["first"] : []), - ...((links.last && index < pageCount) ? ["last"] : []), - ], - meta: { - index, - count, - pageSize, - pageCount, - }, - data: { - links, - }, + actions, + meta: { index, count, pageSize, pageCount }, + data: { links }, }); - } + }; + + getCursor = ({ headers }) => this.getCursorFromHeaders(headers); + + // Gets a cursor without retrieving the entries by using a HEAD + // request + fetchCursor = flow([unsentRequest.withMethod("HEAD"), this.request, then(this.getCursor)]); + fetchCursorAndEntries = flow([ + unsentRequest.withMethod("GET"), + this.request, + p => Promise.all([p.then(this.getCursor), p.then(this.responseToJSON)]), + then(([cursor, entries]) => ({ cursor, entries })), + ]); + fetchRelativeCursor = async (cursor, action) => this.fetchCursor(cursor.data.links[action]); + + reversableActions = Map({ + first: "last", + last: "first", + next: "prev", + prev: "next", + }); + + reverseCursor = cursor => cursor + .updateStore("meta", meta => meta.set("index", meta.get("pageCount", 0) - meta.get("index", 0))) + .updateInStore(["data", "links"], links => links.mapEntries(([k, v]) => [this.reversableActions.get(k, k), v])) + .updateStore("actions", actions => actions.map(action => this.reversableActions.get(action, action))); + + // The exported listFiles and traverseCursor reverse the direction + // of the cursors, since GitLab's pagination sorts the opposite way + // we want to sort by default (it sorts by filename _descending_, + // while the CMS defaults to sorting by filename _ascending_, at + // least in the current GitHub backend). This should eventually be + // refactored. + listFiles = async path => { + const firstPageCursor = await this.fetchCursor({ + url: `${ this.repoURL }/repository/tree`, + params: { path, ref: this.branch }, + }); + const lastPageLink = firstPageCursor.data.getIn(["links", "last"]); + const { entries, cursor } = await this.fetchCursorAndEntries(lastPageLink); + return { files: entries.reverse(), cursor: this.reverseCursor(cursor) }; + }; - traverseCursor(cursor, action) { + traverseCursor = async (cursor, action) => { const link = cursor.data.getIn(["links", action]); - return fetch(link, { headers: this.requestHeaders() }) - .then(res => { - const newCursor = this.getCursor(res); - return this.getResponseData(res) - .then(responseData => ({ entries: responseData.data, cursor: newCursor })); - }); - } + const { entries, cursor: newCursor } = await this.fetchCursorAndEntries(link); + return { entries: entries.reverse(), cursor: this.reverseCursor(newCursor) }; + }; + + toBase64 = str => Promise.resolve(Base64.encode(str)); + fromBase64 = str => Base64.decode(str); + uploadAndCommit = async (item, { commitMessage, updateFile = false, branch = this.branch }) => { + const content = await (item instanceof AssetProxy ? item.toBase64() : this.toBase64(item.raw)); + const file_path = item.path.replace(/^\//, ""); + const action = (updateFile ? "update" : "create"); + const encoding = "base64"; + const body = JSON.stringify({ + branch, + commit_message: commitMessage, + actions: [{ action, file_path, content, encoding }], + }); - listFiles(path) { - return this.request(`${ this.repoURL }/repository/tree`, { - params: { path, ref: this.branch }, - }) - .then(({ response, data }) => { - const files = data.filter(file => file.type === "blob"); - const cursor = this.getCursor(response); - return { files, cursor }; - }); - } + await this.request({ + url: `${ this.repoURL }/repository/commits`, + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); - persistFiles(files, options) { - const uploadOpts = { - commitMessage: options.commitMessage, - updateFile: (options.newEntry === false) || false, - }; + return { ...item, uploaded: true }; + }; - const uploads = files.map(file => this.uploadAndCommit(file, uploadOpts)); - return Promise.all(uploads); - } + persistFiles = (files, { commitMessage, newEntry }) => + Promise.all(files.map(file => this.uploadAndCommit(file, { commitMessage, updateFile: newEntry === false }))); - deleteFile(path, commit_message, options = {}) { + deleteFile = (path, commit_message, options = {}) => { const branch = options.branch || this.branch; - return this.request(`${this.repoURL}/repository/files/${encodeURIComponent(path)}`, { - method: "DELETE", - params: { commit_message, branch }, - }).then(({ data }) => data); - } - - toBase64(str) { - return Promise.resolve(Base64.encode(str)); - } - - fromBase64(str) { - return Base64.decode(str); - } - - uploadAndCommit(item, { commitMessage, updateFile = false, branch = this.branch }) { - const content = item instanceof AssetProxy ? item.toBase64() : this.toBase64(item.raw); - // Remove leading slash from path if exists. - const file_path = item.path.replace(/^\//, ''); - - // We cannot use the `/repository/files/:file_path` format here because the file content has to go - // in the URI as a parameter. This overloads the OPTIONS pre-request (at least in Chrome 61 beta). - return content.then(contentBase64 => this.request(`${this.repoURL}/repository/commits`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - branch, - commit_message: commitMessage, - actions: [{ - action: (updateFile ? "update" : "create"), - file_path, - content: contentBase64, - encoding: "base64", - }], - }), - })).then(() => Object.assign({}, item, { uploaded: true })); - } + return flow([ + unsentRequest.fromURL, + unsentRequest.withMethod("DELETE"), + unsentRequest.withParams({ commit_message, branch }), + this.request, + ])(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }`); + }; } diff --git a/src/components/Collection/Entries/EntryListing.js b/src/components/Collection/Entries/EntryListing.js index 6cb9b3c34b89..4fc363fc1276 100644 --- a/src/components/Collection/Entries/EntryListing.js +++ b/src/components/Collection/Entries/EntryListing.js @@ -5,6 +5,7 @@ import Waypoint from 'react-waypoint'; import { Map } from 'immutable'; import { selectFields, selectInferedField } from 'Reducers/collections'; import EntryCard from './EntryCard'; +import Cursor from 'ValueObjects/Cursor'; export default class EntryListing extends React.Component { static propTypes = { @@ -19,7 +20,7 @@ export default class EntryListing extends React.Component { handleLoadMore = () => { const { cursor, handleCursorActions } = this.props; - if (cursor.actions.has("append_next")) { + if (Cursor.create(cursor).actions.has("append_next")) { handleCursorActions("append_next"); } }; diff --git a/src/lib/promiseHelper.js b/src/lib/promiseHelper.js index 0d16bd5ec8d4..e4866d9bcff0 100644 --- a/src/lib/promiseHelper.js +++ b/src/lib/promiseHelper.js @@ -18,3 +18,5 @@ export const resolvePromiseProperties = (obj) => { // resolved values Object.assign({}, obj, zipObject(promiseKeys, resolvedPromises))); }; + +export const then = fn => p => Promise.resolve(p).then(fn); diff --git a/src/lib/unsentRequest.js b/src/lib/unsentRequest.js new file mode 100644 index 000000000000..26be0e9d7c1a --- /dev/null +++ b/src/lib/unsentRequest.js @@ -0,0 +1,75 @@ +import { fromJS, List, Map } from 'immutable'; +import { curry, flow } from "lodash"; + +const decodeParams = paramsString => List(paramsString.split("&")) + .map(s => List(s.split("=")).map(decodeURIComponent)) + .update(Map); + +const fromURL = wholeURL => { + const [url, allParamsString] = wholeURL.split("?"); + return Map({ url, ...(allParamsString ? { params: decodeParams(allParamsString) } : {}) }); +}; + +const encodeParams = params => params.entrySeq() + .map(([k, v]) => `${ encodeURIComponent(k) }=${ encodeURIComponent(v) }`) + .join("&"); + +const toURL = req => `${ req.get("url") }${ req.get("params") ? `?${ encodeParams(req.get("params")) }` : "" }`; + +const toFetchArguments = req => [toURL(req), req.delete("url").delete("params").toJS()]; + +const maybeRequestArg = req => (req ? fromJS(req) : Map()) +const ensureRequestArg = func => req => func(maybeRequestArg(req)); +const ensureRequestArg2 = func => (arg, req) => func(arg, maybeRequestArg(req)); + +// This actually performs the built request object +const performRequest = ensureRequestArg(req => fetch(...toFetchArguments(req))); + +// Each of the following functions takes options and returns another +// function that performs the requested action on a request. They each +// default to containing an empty object, so you can simply call them +// without arguments to generate a request with only those properties. +const getCurriedRequestProcessor = flow([ensureRequestArg2, curry]); +const getPropSetFunctions = path => [ + getCurriedRequestProcessor((val, req) => req.setIn(path, val)), + getCurriedRequestProcessor((val, req) => (req.getIn(path) ? req : req.setIn(path, val))), +]; +const getPropMergeFunctions = path => [ + getCurriedRequestProcessor((obj, req) => req.updateIn(path, (p=Map()) => p.merge(obj))), + getCurriedRequestProcessor((obj, req) => req.updateIn(path, (p=Map()) => Map(obj).merge(p))), +]; + +const [withMethod, withDefaultMethod] = getPropSetFunctions(["method"]); +const [withBody, withDefaultBody] = getPropSetFunctions(["method"]); +const [withParams, withDefaultParams] = getPropMergeFunctions(["params"]); +const [withHeaders, withDefaultHeaders] = getPropMergeFunctions(["headers"]); + +// withRoot sets a root URL, unless the URL is already absolute +const absolutePath = new RegExp('^(?:[a-z]+:)?//', 'i'); +const withRoot = getCurriedRequestProcessor((root, req) => req.update("url", p => { + if (absolutePath.test(p)) { return p; } + return (root && p && p[0] !== "/" && root[root.length - 1] !== "/") + ? `${ root }/${ p }` + : `${ root }${ p }`; +})); + +// withTimestamp needs no argument and has to run as late as possible, +// so it calls `withParams` only when it's actually called with a +// request. +const withTimestamp = ensureRequestArg(req => withParams({ ts: new Date().getTime() }, req)); + +export default { + toURL, + fromURL, + performRequest, + withMethod, + withDefaultMethod, + withBody, + withDefaultBody, + withHeaders, + withDefaultHeaders, + withParams, + withDefaultParams, + withRoot, + withTimestamp, +}; From 8e634281184063431c255003baff700d4b7c11f0 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Thu, 3 May 2018 16:57:32 -0700 Subject: [PATCH 27/45] Implement search for GitLab --- src/actions/search.js | 129 ++++++-------------------- src/backends/backend.js | 76 ++++++++++++++- src/backends/gitlab/API.js | 17 ++++ src/backends/gitlab/implementation.js | 5 + 4 files changed, 122 insertions(+), 105 deletions(-) diff --git a/src/actions/search.js b/src/actions/search.js index dfc22b99348c..6a9fe59024bd 100644 --- a/src/actions/search.js +++ b/src/actions/search.js @@ -105,121 +105,44 @@ export function clearSearch() { // SearchEntries will search for complete entries in all collections. export function searchEntries(searchTerm, page = 0) { return (dispatch, getState) => { + dispatch(searchingEntries(searchTerm)); + const state = getState(); + const backend = currentBackend(state.config); const allCollections = state.collections.keySeq().toArray(); const collections = allCollections.filter(collection => selectIntegration(state, collection, 'search')); const integration = selectIntegration(state, collections[0], 'search'); - if (!integration) { - localSearch(searchTerm, getState, dispatch); - } else { - const provider = getIntegrationProvider(state.integrations, currentBackend(state.config).getToken, integration); - dispatch(searchingEntries(searchTerm)); - provider.search(collections, searchTerm, page).then( - response => dispatch(searchSuccess(searchTerm, response.entries, response.pagination)), - error => dispatch(searchFailure(searchTerm, error)) - ); - } + + const searchPromise = integration + ? getIntegrationProvider(state.integrations, backend.getToken, integration).search(collections, searchTerm, page) + : backend.search(state.collections.valueSeq().toArray(), searchTerm); + + return searchPromise.then( + response => dispatch(searchSuccess(searchTerm, response.entries, response.pagination)), + error => dispatch(searchFailure(searchTerm, error)) + ); }; } // Instead of searching for complete entries, query will search for specific fields // in specific collections and return raw data (no entries). -export function query(namespace, collection, searchFields, searchTerm) { +export function query(namespace, collectionName, searchFields, searchTerm) { return (dispatch, getState) => { - const state = getState(); - const integration = selectIntegration(state, collection, 'search'); dispatch(querying(namespace, collection, searchFields, searchTerm)); - if (!integration) { - localQuery(namespace, collection, searchFields, searchTerm, state, dispatch); - } else { - const provider = getIntegrationProvider(state.integrations, currentBackend(state.config).getToken, integration); - provider.searchBy(searchFields.map(f => `data.${ f }`), collection, searchTerm).then( - response => dispatch(querySuccess(namespace, collection, searchFields, searchTerm, response)), - error => dispatch(queryFailure(namespace, collection, searchFields, searchTerm, error)) - ); - } - }; -} -// Local Query & Search functions - -function localSearch(searchTerm, getState, dispatch) { - return (function acc(localResults = { entries: [] }) { - function processCollection(collection, collectionKey) { - const state = getState(); - if (state.entries.hasIn(['pages', collectionKey, 'ids'])) { - const searchFields = [ - selectInferedField(collection, 'title'), - selectInferedField(collection, 'shortTitle'), - selectInferedField(collection, 'author'), - ]; - const collectionEntries = selectEntries(state, collectionKey).toJS(); - const filteredEntries = fuzzy.filter(searchTerm, collectionEntries, { - extract: entry => searchFields.reduce((acc, field) => { - const f = entry.data[field]; - return f ? `${ acc } ${ f }` : acc; - }, ""), - }).filter(entry => entry.score > 5); - localResults[collectionKey] = true; - localResults.entries = localResults.entries.concat(filteredEntries); - - const returnedKeys = Object.keys(localResults); - const allCollections = state.collections.keySeq().toArray(); - if (allCollections.every(v => returnedKeys.indexOf(v) !== -1)) { - const sortedResults = localResults.entries.sort((a, b) => { - if (a.score > b.score) return -1; - if (a.score < b.score) return 1; - return 0; - }).map(f => f.original); - if (allCollections.size > 3 || localResults.entries.length > 30) { - console.warn('The Netlify CMS is currently using a Built-in search.' + - '\nWhile this works great for small sites, bigger projects might benefit from a separate search integration.' + - '\nPlease refer to the documentation for more information'); - } - dispatch(searchSuccess(searchTerm, sortedResults, 0)); - } - } else { - // Collection entries aren't loaded yet. - // Dispatch loadEntries and wait before redispatching this action again. - dispatch({ - type: WAIT_UNTIL_ACTION, - predicate: action => (action.type === ENTRIES_SUCCESS && action.payload.collection === collectionKey), - run: () => processCollection(collection, collectionKey), - }); - dispatch(loadEntries(collection)); - } - } - getState().collections.forEach(processCollection); - }()); -} + const state = getState(); + const backend = currentBackend(state.config); + const collection = state.collections.find(collection => collection.get('name') === collectionName); + const integration = selectIntegration(state, collection, 'search'); + const queryPromise = integration + ? getIntegrationProvider(state.integrations, backend.getToken, integration) + .searchBy(searchFields.map(f => `data.${ f }`), collectionName, searchTerm) + : backend.query(collection, searchFields, searchTerm); -function localQuery(namespace, collection, searchFields, searchTerm, state, dispatch) { - // Check if entries in this collection were already loaded - if (state.entries.hasIn(['pages', collection, 'ids'])) { - const entries = selectEntries(state, collection).toJS(); - const filteredEntries = fuzzy.filter(searchTerm, entries, { - extract: entry => searchFields.reduce((acc, field) => { - const f = entry.data[field]; - return f ? `${ acc } ${ f }` : acc; - }, ""), - }).filter(entry => entry.score > 5); - - const resultObj = { - query: searchTerm, - hits: [], - }; - - resultObj.hits = filteredEntries.map(f => f.original); - dispatch(querySuccess(namespace, collection, searchFields, searchTerm, resultObj)); - } else { - // Collection entries aren't loaded yet. - // Dispatch loadEntries and wait before redispatching this action again. - dispatch({ - type: WAIT_UNTIL_ACTION, - predicate: action => (action.type === ENTRIES_SUCCESS && action.payload.collection === collection), - run: dispatch => dispatch(query(namespace, collection, searchFields, searchTerm)), - }); - dispatch(loadEntries(state.collections.get(collection))); - } + return queryPromise.then( + response => dispatch(querySuccess(namespace, collectionName, searchFields, searchTerm, response)), + error => dispatch(queryFailure(namespace, collectionName, searchFields, searchTerm, error)) + ); + }; } diff --git a/src/backends/backend.js b/src/backends/backend.js index 24454d0a03ee..db6f53888ee2 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -1,5 +1,6 @@ -import { attempt, isError } from 'lodash'; -import { Map } from 'immutable'; +import { attempt, flatten, isError } from 'lodash'; +import { fromJS, Map } from 'immutable'; +import fuzzy from 'fuzzy'; import { resolveFormat } from "Formats/formats"; import { selectIntegration } from 'Reducers/integrations'; import { @@ -10,6 +11,7 @@ import { selectAllowDeletion, selectFolderEntryExtension, selectIdentifier, + selectInferedField, } from "Reducers/collections"; import { createEntry } from "ValueObjects/Entry"; import { sanitizeSlug } from "Lib/urlHelper"; @@ -110,6 +112,17 @@ const commitMessageFormatter = (type, config, { slug, path, collection }) => { }); } +const extractSearchFields = searchFields => entry => searchFields.reduce((acc, field) => { + const f = entry.data[field]; + return f ? `${acc} ${f}` : acc; +}, ""); + +const sortByScore = (a, b) => { + if (a.score > b.score) return -1; + if (a.score < b.score) return 1; + return 0; +}; + class Backend { constructor(implementation, backendName, authStore = null) { this.implementation = implementation; @@ -191,6 +204,65 @@ class Backend { })); } + // The same as listEntries, except that if a cursor with the "next" + // action available is returned, it calls "next" on the cursor and + // repeats the process. Once there is no available "next" action, it + // returns all the collected entries. Used to retrieve all entries + // for local searches and queries. + async listAllEntries(collection) { + if (collection.get("folder") && this.implementation.allEntriesByFolder) { + const extension = selectFolderEntryExtension(collection); + return this.implementation.allEntriesByFolder(collection, extension) + .then(entries => this.processEntries(entries, collection)); + } + + const response = await this.listEntries(collection); + const { entries } = response; + let { cursor } = response; + while (cursor && cursor.actions.includes("next")) { + const { entries: newEntries, cursor: newCursor } = await this.traverseCursor(cursor, "next"); + entries.push(...newEntries); + cursor = newCursor; + } + return entries; + } + + async search(collections, searchTerm) { + // Perform a local search by requesting all entries. For each + // collection, load it, search, and call onCollectionResults with + // its results. + const errors = []; + const collectionEntriesRequests = collections.map(async collection => { + // TODO: pass search fields in as an argument + const searchFields = [ + selectInferedField(collection, 'title'), + selectInferedField(collection, 'shortTitle'), + selectInferedField(collection, 'author'), + ]; + const collectionEntries = await this.listAllEntries(collection); + return fuzzy.filter(searchTerm, collectionEntries, { + extract: extractSearchFields(searchFields), + }); + }).map(p => p.catch(err => errors.push(err) && [])); + + const entries = await Promise.all(collectionEntriesRequests).then(arrs => flatten(arrs)); + + if (errors.length > 0) { + throw new Error({ message: "Errors ocurred while searching entries locally!", errors }); + } + const hits = entries.filter(({ score }) => score > 5).sort(sortByScore).map(f => f.original); + return { entries: hits }; + } + + async query(collection, searchFields, searchTerm) { + const entries = await this.listAllEntries(collection); + const hits = fuzzy.filter(searchTerm, entries, { extract: extractSearchFields(searchFields) }) + .filter(entry => entry.score > 5) + .sort(sortByScore) + .map(f => f.original); + return { query: searchTerm, hits }; + } + traverseCursor(cursor, action) { const [data, unwrappedCursor] = cursor.unwrapData(); // TODO: stop assuming all cursors are for collections diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 390700c32fb1..2b98ca6a307f 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -141,6 +141,23 @@ export default class API { return { entries: entries.reverse(), cursor: this.reverseCursor(newCursor) }; }; + listAllEntries = async path => { + const entries = []; + let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({ + url: `${ this.repoURL }/repository/tree`, + // Get the maximum number of entries per page + params: { path, ref: this.branch, per_page: 100 }, + }); + entries.push(...initialEntries); + while (cursor && cursor.actions.has("next")) { + const link = cursor.data.getIn(["links", "next"]); + const { cursor: newCursor, entries: newEntries } = await this.fetchCursorAndEntries(link); + entries.push(...newEntries); + cursor = newCursor; + } + return entries; + }; + toBase64 = str => Promise.resolve(Base64.encode(str)); fromBase64 = str => Base64.decode(str); uploadAndCommit = async (item, { commitMessage, updateFile = false, branch = this.branch }) => { diff --git a/src/backends/gitlab/implementation.js b/src/backends/gitlab/implementation.js index 337e567346a8..662a61d13e1e 100644 --- a/src/backends/gitlab/implementation.js +++ b/src/backends/gitlab/implementation.js @@ -68,6 +68,11 @@ export default class GitLab { ); } + allEntriesByFolder(collection, extension) { + return this.api.listAllEntries(collection.get("folder"), extension) + .then(files => this.fetchFiles(files.filter(file => fileExtension(file.name) === extension))); + } + entriesByFiles(collection) { const files = collection.get("files").map(collectionFile => ({ path: collectionFile.get("file"), From 05c2f79b89970020e442c5616dbcf1031144ac90 Mon Sep 17 00:00:00 2001 From: Caleb Date: Mon, 23 Apr 2018 18:46:06 -0600 Subject: [PATCH 28/45] Add implicit OAuth for GitLab. --- src/backends/gitlab/AuthenticationPage.js | 34 ++++++++--- src/lib/implicit-oauth.js | 71 +++++++++++++++++++++++ src/lib/randomGenerator.js | 33 ++++------- 3 files changed, 107 insertions(+), 31 deletions(-) create mode 100644 src/lib/implicit-oauth.js diff --git a/src/backends/gitlab/AuthenticationPage.js b/src/backends/gitlab/AuthenticationPage.js index cbadc87d6ee6..616d86fca6b7 100644 --- a/src/backends/gitlab/AuthenticationPage.js +++ b/src/backends/gitlab/AuthenticationPage.js @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; import React from 'react'; -import Authenticator from 'Lib/netlify-auth'; +import NetlifyAuthenticator from 'Lib/netlify-auth'; +import ImplicitAuthenticator from 'Lib/implicit-oauth'; import { Icon } from 'UI'; export default class AuthenticationPage extends React.Component { @@ -11,15 +12,32 @@ export default class AuthenticationPage extends React.Component { state = {}; + componentDidMount() { + const authType = this.props.config.getIn(['backend', 'auth_type']); + if (authType === "implicit") { + this.auth = new ImplicitAuthenticator({ + auth_url: this.props.config.getIn(['backend', 'auth_url'], 'https://gitlab.com/oauth/authorize'), + appid: this.props.config.getIn(['backend', 'appid']), + }); + // Complete implicit authentication if we were redirected back to from the provider. + this.auth.completeAuth((err, data) => { + if (err) { + this.setState({ loginError: err.toString() }); + return; + } + this.props.onLogin(data); + }); + } else { + this.auth = new NetlifyAuthenticator({ + base_url: this.props.base_url, + site_id: (document.location.host.split(':')[0] === 'localhost') ? 'cms.netlify.com' : this.props.siteId + }); + } + } + handleLogin = (e) => { e.preventDefault(); - const cfg = { - base_url: this.props.base_url, - site_id: (document.location.host.split(':')[0] === 'localhost') ? 'cms.netlify.com' : this.props.siteId - }; - const auth = new Authenticator(cfg); - - auth.authenticate({ provider: 'gitlab', scope: 'api' }, (err, data) => { + this.auth.authenticate({ provider: 'gitlab', scope: 'api' }, (err, data) => { if (err) { this.setState({ loginError: err.toString() }); return; diff --git a/src/lib/implicit-oauth.js b/src/lib/implicit-oauth.js new file mode 100644 index 000000000000..6aab599f74fa --- /dev/null +++ b/src/lib/implicit-oauth.js @@ -0,0 +1,71 @@ +import { Map } from 'immutable'; +import { randomStr } from 'Lib/randomGenerator'; +import history from 'Routing/history'; + +function createNonce() { + const nonce = randomStr(); + window.sessionStorage.setItem("netlify-cms-auth", JSON.stringify({ nonce })); + return nonce; +} + +function validateNonce(check) { + const auth = window.sessionStorage.getItem("netlify-cms-auth"); + const valid = auth && JSON.parse(auth).nonce; + window.localStorage.removeItem("netlify-cms-auth"); + return (check === valid); +} + +export default class ImplicitAuthenticator { + constructor(config = {}) { + this.auth_url = config.auth_url; + this.appid = config.appid; + } + + authenticate(options, cb) { + if ( + document.location.protocol !== "https:" + // TODO: Is insecure localhost a bad idea as well? I don't think it is, since you are not actually + // sending the token over the internet in this case, assuming the auth URL is secure. + && (document.location.hostname !== "localhost" && document.location.hostname !== "127.0.0.1") + ) { + return cb(new Error("Cannot authenticate over insecure protocol!")); + } + + const authURL = new URL(this.auth_url); + authURL.searchParams.set('client_id', this.appid); + authURL.searchParams.set('redirect_uri', document.location.origin + document.location.pathname); + authURL.searchParams.set('response_type', 'token'); + authURL.searchParams.set('scope', options.scope); + authURL.searchParams.set('state', createNonce()); + + document.location.assign(authURL.href); + } + + /** + * Complete authentication if we were redirected back to from the provider. + */ + completeAuth(cb) { + const hashParams = new URLSearchParams(document.location.hash.replace(/^#?\/?/, '')); + if (!hashParams.has("access_token") && !hashParams.has("error")) { + return; + } + // Remove tokens from hash so that token does not remain in browser history. + history.replace('/'); + + const params = Map(hashParams.entries()); + + const validNonce = validateNonce(params.get('state')); + if (!validNonce) { + return cb(new Error("Invalid nonce")); + } + + if (params.has('error')) { + return cb(new Error(`${ params.get('error') }: ${ params.get('error_description') }`)); + } + + if (params.has('access_token')) { + const { access_token: token, ...data } = params.toJS(); + cb(null, { token, ...data }); + } + } +} diff --git a/src/lib/randomGenerator.js b/src/lib/randomGenerator.js index 7d73aadc08c7..51831702df57 100644 --- a/src/lib/randomGenerator.js +++ b/src/lib/randomGenerator.js @@ -2,30 +2,17 @@ * Random number generator */ -let rng; - -if (window.crypto && crypto.getRandomValues) { - // WHATWG crypto-based RNG - http://wiki.whatwg.org/wiki/Crypto - // Moderately fast, high quality - const _rnds32 = new Uint32Array(1); - rng = function whatwgRNG() { - crypto.getRandomValues(_rnds32); - return _rnds32[0]; - }; +const padNumber = (num, base) => { + const padLen = (32 / Math.sqrt(base)); + const str = num.toString(base); + return (('0' * padLen) + str).slice(-padLen); } -if (!rng) { - // Math.random()-based (RNG) - // If no Crypto available, use Math.random(). - rng = function() { - const r = Math.random() * 0x100000000; - const _rnds = r >>> 0; - return _rnds; - }; -} +export function randomStr(len = 256) { + const _rnds = new Uint32Array(Math.ceil(len / 32)); + window.crypto.getRandomValues(_rnds); -export function randomStr() { - return rng().toString(36); -} + const str = _rnds.reduce((agg, val) => (agg + padNumber(val, 16)), ''); -export default rng; + return str.slice(-len); +} \ No newline at end of file From 39af3d6fdbfc08435044600cfe81657fffb99246 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Mon, 4 Jun 2018 21:09:14 -0700 Subject: [PATCH 29/45] Fetch all files when listing media from GitLab --- src/backends/gitlab/API.js | 8 ++++---- src/backends/gitlab/implementation.js | 9 +++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 2b98ca6a307f..6468a22103c3 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -1,12 +1,12 @@ import LocalForage from "Lib/LocalForage"; import { Base64 } from "js-base64"; -import { fromJS, List, Map } from "immutable"; -import { cond, flow, isString, partial, partialRight, omit, set, update } from "lodash"; +import { List, Map } from "immutable"; +import { flow } from "lodash"; import unsentRequest from "Lib/unsentRequest"; import { then } from "Lib/promiseHelper"; import AssetProxy from "ValueObjects/AssetProxy"; import { APIError } from "ValueObjects/errors"; -import Cursor from "ValueObjects/Cursor" +import Cursor from "ValueObjects/Cursor"; export default class API { constructor(config) { @@ -141,7 +141,7 @@ export default class API { return { entries: entries.reverse(), cursor: this.reverseCursor(newCursor) }; }; - listAllEntries = async path => { + listAllFiles = async path => { const entries = []; let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({ url: `${ this.repoURL }/repository/tree`, diff --git a/src/backends/gitlab/implementation.js b/src/backends/gitlab/implementation.js index 662a61d13e1e..926aa26c6d44 100644 --- a/src/backends/gitlab/implementation.js +++ b/src/backends/gitlab/implementation.js @@ -69,7 +69,7 @@ export default class GitLab { } allEntriesByFolder(collection, extension) { - return this.api.listAllEntries(collection.get("folder"), extension) + return this.api.listAllFiles(collection.get("folder")) .then(files => this.fetchFiles(files.filter(file => fileExtension(file.name) === extension))); } @@ -110,11 +110,8 @@ export default class GitLab { } getMedia() { - // TODO: list the entire folder to get all media files instead of - // just the first page, or implement cursor UI for the media - // library. - return this.api.listFiles(this.config.get('media_folder')) - .then(({ files }) => files.map(({ id, name, path }) => { + return this.api.listAllFiles(this.config.get('media_folder')) + .then(files => files.map(({ id, name, path }) => { const url = new URL(this.api.fileDownloadURL(path)); if (url.pathname.match(/.svg$/)) { url.search += (url.search.slice(1) === '' ? '?' : '&') + 'sanitize=true'; From 50404dd903482c6dd13320a3e62eb30357921476 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Mon, 4 Jun 2018 22:27:18 -0700 Subject: [PATCH 30/45] Remove unnecessary imports in GitLab API --- src/backends/gitlab/API.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 6468a22103c3..3af0e8278030 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -1,12 +1,12 @@ import LocalForage from "Lib/LocalForage"; import { Base64 } from "js-base64"; -import { List, Map } from "immutable"; -import { flow } from "lodash"; +import { fromJS, List, Map } from "immutable"; +import { cond, flow, isString, partial, partialRight, omit, set, update } from "lodash"; import unsentRequest from "Lib/unsentRequest"; import { then } from "Lib/promiseHelper"; import AssetProxy from "ValueObjects/AssetProxy"; import { APIError } from "ValueObjects/errors"; -import Cursor from "ValueObjects/Cursor"; +import Cursor from "ValueObjects/Cursor" export default class API { constructor(config) { From 1b6836447169b0fbe452868fba5e59b47075639d Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Tue, 5 Jun 2018 12:29:28 -0700 Subject: [PATCH 31/45] Improve the readability of reverseCursor in GitLab API --- src/backends/gitlab/API.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 3af0e8278030..0dbe57b20222 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -114,10 +114,21 @@ export default class API { prev: "next", }); - reverseCursor = cursor => cursor - .updateStore("meta", meta => meta.set("index", meta.get("pageCount", 0) - meta.get("index", 0))) - .updateInStore(["data", "links"], links => links.mapEntries(([k, v]) => [this.reversableActions.get(k, k), v])) - .updateStore("actions", actions => actions.map(action => this.reversableActions.get(action, action))); + reverseCursor = cursor => { + const pageCount = cursor.meta.get("pageCount", 0); + const currentIndex = cursor.meta.get("index", 0); + const newIndex = pageCount - currentIndex; + + const links = cursor.data.get("links", Map()); + const reversedLinks = links.mapEntries(([k, v]) => [this.reversableActions.get(k) || k, v]); + + const reversedActions = cursor.actions.map(action => this.reversableActions.get(action) || action); + + return cursor.updateStore(store => store + .setIn(["meta", "index"], newIndex) + .setIn(["data", "links"], reversedLinks) + .set("actions", reversedActions)); + }; // The exported listFiles and traverseCursor reverse the direction // of the cursors, since GitLab's pagination sorts the opposite way From 01d7667965b35967af3705e45d39b0afaef5a1aa Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Tue, 5 Jun 2018 12:34:44 -0700 Subject: [PATCH 32/45] Improve error handling and request parsing in GitLab API --- src/backends/gitlab/API.js | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 0dbe57b20222..e586c5aec7c9 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -26,10 +26,30 @@ export default class API { unsentRequest.withTimestamp, ])(req); - request = async req => flow([this.buildRequest, unsentRequest.performRequest])(req); requestURL = url => flow([unsentRequest.fromURL, this.request])(url); - responseToJSON = res => res.json() - responseToText = res => res.text() + request = async req => flow([ + this.buildRequest, + unsentRequest.performRequest, + p => p.catch(err => Promise.reject(new APIError(err.message, null, "GitLab"))), + ])(req); + + parseResponse = async (res, { expectingOk=true, expectingFormat=false }) => { + const contentType = res.headers.get("Content-Type"); + const isJSON = contentType === "application/json"; + let body; + try { + body = await ((expectingFormat === "json" || isJSON) ? res.json() : res.text()); + } catch (err) { + throw new APIError(err.message, res.status, "GitLab"); + } + if (expectingOk && !res.ok) { + throw new APIError((isJSON && body.message) ? body.message : body, res.status, "GitLab"); + } + return body; + }; + + responseToJSON = res => this.parseResponse(res, { expectingFormat: "json" }); + responseToText = res => this.parseResponse(res, { expectingFormat: "text" }); requestJSON = req => this.request(req).then(this.responseToJSON); requestText = req => this.request(req).then(this.responseToText); requestJSONFromURL = url => this.requestURL(url).then(this.responseToJSON); From 110785f7cdf05c4503dc9a9def1f94f65a31a7c6 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Tue, 5 Jun 2018 12:38:04 -0700 Subject: [PATCH 33/45] Automatically call fromURL on string args to unsentRequest functions --- src/backends/gitlab/API.js | 8 ++------ src/lib/unsentRequest.js | 8 ++++++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index e586c5aec7c9..f3e384f74554 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -26,7 +26,6 @@ export default class API { unsentRequest.withTimestamp, ])(req); - requestURL = url => flow([unsentRequest.fromURL, this.request])(url); request = async req => flow([ this.buildRequest, unsentRequest.performRequest, @@ -52,13 +51,11 @@ export default class API { responseToText = res => this.parseResponse(res, { expectingFormat: "text" }); requestJSON = req => this.request(req).then(this.responseToJSON); requestText = req => this.request(req).then(this.responseToText); - requestJSONFromURL = url => this.requestURL(url).then(this.responseToJSON); - requestTextFromURL = url => this.requestURL(url).then(this.responseToText); - user = () => this.requestJSONFromURL("/user"); + user = () => this.requestJSON("/user"); WRITE_ACCESS = 30; - hasWriteAccess = user => this.requestJSONFromURL(this.repoURL).then(({ permissions }) => { + hasWriteAccess = user => this.requestJSON(this.repoURL).then(({ permissions }) => { const { project_access, group_access } = permissions; if (project_access && (project_access.access_level >= this.WRITE_ACCESS)) { return true; @@ -218,7 +215,6 @@ export default class API { deleteFile = (path, commit_message, options = {}) => { const branch = options.branch || this.branch; return flow([ - unsentRequest.fromURL, unsentRequest.withMethod("DELETE"), unsentRequest.withParams({ commit_message, branch }), this.request, diff --git a/src/lib/unsentRequest.js b/src/lib/unsentRequest.js index 26be0e9d7c1a..19f3cec7156f 100644 --- a/src/lib/unsentRequest.js +++ b/src/lib/unsentRequest.js @@ -1,5 +1,5 @@ import { fromJS, List, Map } from 'immutable'; -import { curry, flow } from "lodash"; +import { curry, flow, isString } from "lodash"; const decodeParams = paramsString => List(paramsString.split("&")) .map(s => List(s.split("=")).map(decodeURIComponent)) @@ -18,7 +18,11 @@ const toURL = req => `${ req.get("url") }${ req.get("params") ? `?${ encodeParam const toFetchArguments = req => [toURL(req), req.delete("url").delete("params").toJS()]; -const maybeRequestArg = req => (req ? fromJS(req) : Map()) +const maybeRequestArg = req => { + if (isString(req)) { return fromURL(req); } + if (req) { return fromJS(req); } + return Map(); +}; const ensureRequestArg = func => req => func(maybeRequestArg(req)); const ensureRequestArg2 = func => (arg, req) => func(arg, maybeRequestArg(req)); From 3bfe9e3bda24e537823335d629d39d8f142a86ae Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Tue, 5 Jun 2018 12:39:25 -0700 Subject: [PATCH 34/45] Filter folders from file-listing functions in GitLab API --- src/backends/gitlab/API.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index f3e384f74554..52c2c29d0f3b 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -160,7 +160,7 @@ export default class API { }); const lastPageLink = firstPageCursor.data.getIn(["links", "last"]); const { entries, cursor } = await this.fetchCursorAndEntries(lastPageLink); - return { files: entries.reverse(), cursor: this.reverseCursor(cursor) }; + return { files: entries.filter(({ type }) => type === "blob").reverse(), cursor: this.reverseCursor(cursor) }; }; traverseCursor = async (cursor, action) => { @@ -183,7 +183,7 @@ export default class API { entries.push(...newEntries); cursor = newCursor; } - return entries; + return entries.filter(({ type }) => type === "blob"); }; toBase64 = str => Promise.resolve(Base64.encode(str)); From bf410a468088602310e4dc8cfc7714a795540194 Mon Sep 17 00:00:00 2001 From: Shawn Erquhart Date: Tue, 5 Jun 2018 15:27:39 -0400 Subject: [PATCH 35/45] print error messages in error boundary component --- .../UI/ErrorBoundary/ErrorBoundary.js | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/components/UI/ErrorBoundary/ErrorBoundary.js b/src/components/UI/ErrorBoundary/ErrorBoundary.js index d0d0e364d930..a8447e6d99a1 100644 --- a/src/components/UI/ErrorBoundary/ErrorBoundary.js +++ b/src/components/UI/ErrorBoundary/ErrorBoundary.js @@ -1,35 +1,36 @@ import PropTypes from 'prop-types'; import React from 'react'; -const ErrorComponent = () => { - const issueUrl = "https://github.com/netlify/netlify-cms/issues/new"; - return ( -
-

Sorry!

-

- There's been an error - please - report it! -

-
- ); +const DefaultErrorComponent = () => { }; -export class ErrorBoundary extends React.Component { - static propTypes = { - render: PropTypes.element, - }; +const ISSUE_URL = "https://github.com/netlify/netlify-cms/issues/new"; +export class ErrorBoundary extends React.Component { state = { hasError: false, + errorMessage: '', }; componentDidCatch(error) { console.error(error); - this.setState({ hasError: true }); + this.setState({ hasError: true, errorMessage: error.toString() }); } render() { - const errorComponent = this.props.errorComponent || ; - return this.state.hasError ? errorComponent : this.props.children; + const { hasError, errorMessage } = this.state; + if (!hasError) { + return this.props.children; + } + return ( +
+

Sorry!

+

+ There's been an error - please + report it! +

+

{errorMessage}

+
+ ); } } From 40438e866cce0b68fb8518ee94397c666d1f99c0 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Tue, 5 Jun 2018 19:31:23 -0700 Subject: [PATCH 36/45] Git Gateway: lazy loading and GitLab support --- src/backends/git-gateway/GitHubAPI.js | 106 ++++++++++++++++ src/backends/git-gateway/GitLabAPI.js | 25 ++++ src/backends/git-gateway/implementation.js | 135 +++++++++++++-------- src/backends/github/implementation.js | 11 +- src/backends/gitlab/API.js | 11 +- src/backends/gitlab/implementation.js | 11 +- 6 files changed, 240 insertions(+), 59 deletions(-) create mode 100644 src/backends/git-gateway/GitHubAPI.js create mode 100644 src/backends/git-gateway/GitLabAPI.js diff --git a/src/backends/git-gateway/GitHubAPI.js b/src/backends/git-gateway/GitHubAPI.js new file mode 100644 index 000000000000..332c7ced6bab --- /dev/null +++ b/src/backends/git-gateway/GitHubAPI.js @@ -0,0 +1,106 @@ +import GithubAPI from "Backends/github/API"; +import { APIError } from "ValueObjects/errors"; + +export default class API extends GithubAPI { + constructor(config) { + super(config); + this.api_root = config.api_root; + this.tokenPromise = config.tokenPromise; + this.commitAuthor = config.commitAuthor; + this.repoURL = ""; + } + + hasWriteAccess() { + return this.getBranch() + .then(() => true) + .catch(error => { + if (error.status === 401) { + if (error.message === "Bad credentials") { + throw new APIError("Git Gateway Error: Please ask your site administrator to reissue the Git Gateway token.", error.status, 'Git Gateway'); + } else { + return false; + } + } else if (error.status === 404 && (error.message === undefined || error.message === "Unable to locate site configuration")) { + throw new APIError(`Git Gateway Error: Please make sure Git Gateway is enabled on your site.`, error.status, 'Git Gateway'); + } else { + console.error("Problem fetching repo data from Git Gateway"); + throw error; + } + }); + } + + getRequestHeaders(headers = {}) { + return this.tokenPromise() + .then((jwtToken) => { + const baseHeader = { + "Authorization": `Bearer ${ jwtToken }`, + "Content-Type": "application/json", + ...headers, + }; + + return baseHeader; + }); + } + + + urlFor(path, options) { + const cacheBuster = new Date().getTime(); + const params = [`ts=${ cacheBuster }`]; + if (options.params) { + for (const key in options.params) { + params.push(`${ key }=${ encodeURIComponent(options.params[key]) }`); + } + } + if (params.length) { + path += `?${ params.join("&") }`; + } + return this.api_root + path; + } + + user() { + return Promise.resolve(this.commitAuthor); + } + + request(path, options = {}) { + const url = this.urlFor(path, options); + let responseStatus; + return this.getRequestHeaders(options.headers || {}) + .then(headers => fetch(url, { ...options, headers })) + .then((response) => { + responseStatus = response.status; + const contentType = response.headers.get("Content-Type"); + if (contentType && contentType.match(/json/)) { + return this.parseJsonResponse(response); + } + const text = response.text(); + if (!response.ok) { + return Promise.reject(text); + } + return text; + }) + .catch(error => { + throw new APIError((error.message || error.msg), responseStatus, 'Git Gateway'); + }); + } + + commit(message, changeTree) { + const commitParams = { + message, + tree: changeTree.sha, + parents: changeTree.parentSha ? [changeTree.parentSha] : [], + }; + + if (this.commitAuthor) { + commitParams.author = { + ...this.commitAuthor, + date: new Date().toISOString(), + }; + } + + return this.request("/git/commits", { + method: "POST", + body: JSON.stringify(commitParams), + }); + } + +} diff --git a/src/backends/git-gateway/GitLabAPI.js b/src/backends/git-gateway/GitLabAPI.js new file mode 100644 index 000000000000..b504f24bac53 --- /dev/null +++ b/src/backends/git-gateway/GitLabAPI.js @@ -0,0 +1,25 @@ +import { flow } from "lodash"; +import unsentRequest from "Lib/unsentRequest"; +import { then } from "Lib/promiseHelper"; +import GitlabAPI from "Backends/gitlab/API"; + +export default class API extends GitlabAPI { + constructor(config) { + super(config); + this.tokenPromise = config.tokenPromise; + this.commitAuthor = config.commitAuthor; + this.repoURL = ""; + } + + authenticateRequest = async req => console.log("authenticateRequest", req.toJS()) || unsentRequest.withHeaders({ + Authorization: `Bearer ${ await this.tokenPromise() }`, + }, req); + + request = async req => flow([ + this.buildRequest, + this.authenticateRequest, + then(unsentRequest.performRequest), + ])(req); + + hasWriteAccess = () => Promise.resolve(true) +} diff --git a/src/backends/git-gateway/implementation.js b/src/backends/git-gateway/implementation.js index fe9b8ea0b6b0..0eec62b494a5 100644 --- a/src/backends/git-gateway/implementation.js +++ b/src/backends/git-gateway/implementation.js @@ -3,7 +3,9 @@ import jwtDecode from 'jwt-decode'; import {List} from 'immutable'; import { get, pick, intersection } from "lodash"; import GitHubBackend from "Backends/github/implementation"; -import API from "./API"; +import GitLabBackend from "Backends/gitlab/implementation"; +import GitHubAPI from "./GitHubAPI"; +import GitLabAPI from "./GitLabAPI"; import AuthenticationPage from "./AuthenticationPage"; const localHosts = { @@ -29,65 +31,89 @@ function getEndpoint(endpoint, netlifySiteURL) { return endpoint; } -export default class GitGateway extends GitHubBackend { +export default class GitGateway { constructor(config) { - super(config, true); - - this.accept_roles = (config.getIn(["backend", "accept_roles"]) || List()).toArray(); + this.config = config; + this.branch = config.getIn(["backend", "branch"], "master").trim(); + this.squash_merges = config.getIn(["backend", "squash_merges"]); const netlifySiteURL = localStorage.getItem("netlifySiteURL"); const APIUrl = getEndpoint(config.getIn(["backend", "identity_url"], defaults.identity), netlifySiteURL); - this.github_proxy_url = getEndpoint(config.getIn(["backend", "gateway_url"], defaults.gateway), netlifySiteURL); - this.authClient = window.netlifyIdentity ? window.netlifyIdentity.gotrue : new GoTrue({APIUrl}); + this.gatewayUrl = getEndpoint(config.getIn(["backend", "gateway_url"], defaults.gateway), netlifySiteURL); + + const backendTypeRegex = /\/(github|gitlab)\/?$/; + const backendTypeMatches = this.gatewayUrl.match(backendTypeRegex); + if (backendTypeMatches) { + this.backendType = backendTypeMatches[1]; + this.gatewayUrl = this.gatewayUrl.replace(backendTypeRegex, "/"); + console.log({ backendType: this.backendType, gatewayUrl: this.gatewayUrl }); + } else { + this.backendType = null; + } + this.authClient = window.netlifyIdentity ? window.netlifyIdentity.gotrue : new GoTrue({ APIUrl }); AuthenticationPage.authClient = this.authClient; - } - restoreUser() { - const user = this.authClient && this.authClient.currentUser(); - if (!user) return Promise.reject(); - return this.authenticate(user); + this.backend = null; } - authenticate(user) { this.tokenPromise = user.jwt.bind(user); - return this.tokenPromise() - .then((token) => { - let validRole = true; - if (this.accept_roles && this.accept_roles.length > 0) { + return this.tokenPromise().then(async token => { + if (!this.backendType) { + const { github_enabled, gitlab_enabled, roles } = await fetch(`${ this.gatewayUrl }/settings`, { + headers: { Authorization: `Bearer ${ token }` }, + }).then(res => res.json()); + this.acceptRoles = roles; + if (github_enabled) { + this.backendType = "github"; + } else if (gitlab_enabled) { + this.backendType = "gitlab"; + } + } + + if (this.acceptRoles && this.acceptRoles.length > 0) { const userRoles = get(jwtDecode(token), 'app_metadata.roles', []); - validRole = intersection(userRoles, this.accept_roles).length > 0; + const validRole = intersection(userRoles, this.acceptRoles).length > 0; + if (!validRole) { + throw new Error("You don't have sufficient permissions to access Netlify CMS"); + } + } + + const userData = { + name: user.user_metadata.name || user.email.split('@').shift(), + email: user.email, + avatar_url: user.user_metadata.avatar_url, + metadata: user.user_metadata, + }; + const apiConfig = { + api_root: `${ this.gatewayUrl }/${ this.backendType }`, + branch: this.branch, + tokenPromise: this.tokenPromise, + commitAuthor: pick(userData, ["name", "email"]), + squash_merges: this.squash_merges, + }; + + if (this.backendType === "github") { + this.api = new GitHubAPI(apiConfig); + this.backend = new GitHubBackend(this.config, { proxied: true, API: this.api }); + } else if (this.backendType === "gitlab") { + this.api = new GitLabAPI(apiConfig); + this.backend = new GitLabBackend(this.config, { proxied: true, API: this.api }); } - if (validRole) { - const userData = { - name: user.user_metadata.name || user.email.split('@').shift(), - email: user.email, - avatar_url: user.user_metadata.avatar_url, - metadata: user.user_metadata, - }; - this.api = new API({ - api_root: this.github_proxy_url, - branch: this.branch, - tokenPromise: this.tokenPromise, - commitAuthor: pick(userData, ["name", "email"]), - squash_merges: this.squash_merges, - }); - return userData; - } else { + + if (!(await this.api.hasWriteAccess())) { throw new Error("You don't have sufficient permissions to access Netlify CMS"); } - }) - .then(userData => - this.api.hasWriteAccess().then(canWrite => { - if (canWrite) { - return userData; - } else { - throw new Error("You don't have sufficient permissions to access Netlify CMS"); - } - }) - ); + }); + } + restoreUser() { + const user = this.authClient && this.authClient.currentUser(); + if (!user) return Promise.reject(); + return this.authenticate(user); + } + authComponent() { + return AuthenticationPage; } - logout() { if (window.netlifyIdentity) { return window.netlifyIdentity.logout(); @@ -95,13 +121,22 @@ export default class GitGateway extends GitHubBackend { const user = this.authClient.currentUser(); return user && user.logout(); } - getToken() { return this.tokenPromise(); } - authComponent() { - return AuthenticationPage; - } - + entriesByFolder(collection, extension) { return this.backend.entriesByFolder(collection, extension); } + entriesByFiles(collection) { return this.backend.entriesByFiles(collection); } + fetchFiles(files) { return this.backend.fetchFiles(files); } + getEntry(collection, slug, path) { return this.backend.getEntry(collection, slug, path); } + getMedia() { return this.backend.getMedia(); } + persistEntry(entry, mediaFiles, options) { return this.backend.persistEntry(entry, mediaFiles, options); } + persistMedia(mediaFile, options) { return this.backend.persistMedia(mediaFile, options); } + deleteFile(path, commitMessage, options) { return this.backend.deleteFile(path, commitMessage, options); } + unpublishedEntries() { return this.backend.unpublishedEntries(); } + unpublishedEntry(collection, slug) { return this.backend.unpublishedEntry(collection, slug); } + updateUnpublishedEntryStatus(collection, slug, newStatus) { return this.backend.updateUnpublishedEntryStatus(collection, slug, newStatus); } + deleteUnpublishedEntry(collection, slug) { return this.backend.deleteUnpublishedEntry(collection, slug); } + publishUnpublishedEntry(collection, slug) { return this.backend.publishUnpublishedEntry(collection, slug); } + traverseCursor(cursor, action) { return this.backend.traverseCursor(cursor, action); } } diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index 2fa63f4b72aa..92af8b4cccf0 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -6,13 +6,20 @@ import API from "./API"; const MAX_CONCURRENT_DOWNLOADS = 10; export default class GitHub { - constructor(config, proxied = false) { + constructor(config, options) { this.config = config; + this.options = { + proxied: false, + API: null, + ...options, + }; - if (!proxied && config.getIn(["backend", "repo"]) == null) { + if (!this.options.proxied && config.getIn(["backend", "repo"]) == null) { throw new Error("The GitHub backend needs a \"repo\" in the backend configuration."); } + this.api = this.options.API || null; + this.repo = config.getIn(["backend", "repo"], ""); this.branch = config.getIn(["backend", "branch"], "master").trim(); this.api_root = config.getIn(["backend", "api_root"], "https://api.github.com"); diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index 52c2c29d0f3b..7cbdff7797fe 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -1,7 +1,7 @@ import LocalForage from "Lib/LocalForage"; import { Base64 } from "js-base64"; import { fromJS, List, Map } from "immutable"; -import { cond, flow, isString, partial, partialRight, omit, set, update } from "lodash"; +import { cond, flow, isString, partial, partialRight, pick, omit, set, update } from "lodash"; import unsentRequest from "Lib/unsentRequest"; import { then } from "Lib/promiseHelper"; import AssetProxy from "ValueObjects/AssetProxy"; @@ -115,13 +115,13 @@ export default class API { // Gets a cursor without retrieving the entries by using a HEAD // request - fetchCursor = flow([unsentRequest.withMethod("HEAD"), this.request, then(this.getCursor)]); - fetchCursorAndEntries = flow([ + fetchCursor = req => flow([unsentRequest.withMethod("HEAD"), this.request, then(this.getCursor)])(req); + fetchCursorAndEntries = req => flow([ unsentRequest.withMethod("GET"), this.request, p => Promise.all([p.then(this.getCursor), p.then(this.responseToJSON)]), then(([cursor, entries]) => ({ cursor, entries })), - ]); + ])(req); fetchRelativeCursor = async (cursor, action) => this.fetchCursor(cursor.data.links[action]); reversableActions = Map({ @@ -188,11 +188,12 @@ export default class API { toBase64 = str => Promise.resolve(Base64.encode(str)); fromBase64 = str => Base64.decode(str); - uploadAndCommit = async (item, { commitMessage, updateFile = false, branch = this.branch }) => { + uploadAndCommit = async (item, { commitMessage, updateFile = false, branch = this.branch, author = this.commitAuthor }) => { const content = await (item instanceof AssetProxy ? item.toBase64() : this.toBase64(item.raw)); const file_path = item.path.replace(/^\//, ""); const action = (updateFile ? "update" : "create"); const encoding = "base64"; + const { name: author_name, email: author_email } = pick(author || {}, ["name", "email"]); const body = JSON.stringify({ branch, commit_message: commitMessage, diff --git a/src/backends/gitlab/implementation.js b/src/backends/gitlab/implementation.js index 926aa26c6d44..440abc82efe4 100644 --- a/src/backends/gitlab/implementation.js +++ b/src/backends/gitlab/implementation.js @@ -9,17 +9,24 @@ import { EDITORIAL_WORKFLOW } from "Constants/publishModes"; const MAX_CONCURRENT_DOWNLOADS = 10; export default class GitLab { - constructor(config, proxied = false) { + constructor(config, options) { this.config = config; + this.options = { + proxied: false, + API: null, + ...options, + }; if (config.getIn(["publish_mode"]) === EDITORIAL_WORKFLOW) { throw new Error("The GitLab backend does not support the Editorial Workflow.") } - if (!proxied && config.getIn(["backend", "repo"]) == null) { + if (!options.proxied && config.getIn(["backend", "repo"]) == null) { throw new Error("The GitLab backend needs a \"repo\" in the backend configuration."); } + this.api = this.options.API || null; + this.repo = config.getIn(["backend", "repo"], ""); this.branch = config.getIn(["backend", "branch"], "master"); this.api_root = config.getIn(["backend", "api_root"], "https://gitlab.com/api/v4"); From 28d6ce573eec9e1e3fbada17d33da78ec548a092 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Thu, 7 Jun 2018 13:32:21 -0700 Subject: [PATCH 37/45] Rename appid to app_id --- src/backends/gitlab/AuthenticationPage.js | 2 +- src/lib/implicit-oauth.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backends/gitlab/AuthenticationPage.js b/src/backends/gitlab/AuthenticationPage.js index 616d86fca6b7..949dfd3d070f 100644 --- a/src/backends/gitlab/AuthenticationPage.js +++ b/src/backends/gitlab/AuthenticationPage.js @@ -17,7 +17,7 @@ export default class AuthenticationPage extends React.Component { if (authType === "implicit") { this.auth = new ImplicitAuthenticator({ auth_url: this.props.config.getIn(['backend', 'auth_url'], 'https://gitlab.com/oauth/authorize'), - appid: this.props.config.getIn(['backend', 'appid']), + appID: this.props.config.getIn(['backend', 'app_id']), }); // Complete implicit authentication if we were redirected back to from the provider. this.auth.completeAuth((err, data) => { diff --git a/src/lib/implicit-oauth.js b/src/lib/implicit-oauth.js index 6aab599f74fa..2970a559f291 100644 --- a/src/lib/implicit-oauth.js +++ b/src/lib/implicit-oauth.js @@ -18,7 +18,7 @@ function validateNonce(check) { export default class ImplicitAuthenticator { constructor(config = {}) { this.auth_url = config.auth_url; - this.appid = config.appid; + this.appID = config.app_id; } authenticate(options, cb) { @@ -32,7 +32,7 @@ export default class ImplicitAuthenticator { } const authURL = new URL(this.auth_url); - authURL.searchParams.set('client_id', this.appid); + authURL.searchParams.set('client_id', this.appID); authURL.searchParams.set('redirect_uri', document.location.origin + document.location.pathname); authURL.searchParams.set('response_type', 'token'); authURL.searchParams.set('scope', options.scope); From 1669f29e7267418250ae4aed7e02c3668664bdc2 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Thu, 7 Jun 2018 13:37:27 -0700 Subject: [PATCH 38/45] GitLab: fix collection failure if entry loading fails --- src/backends/gitlab/implementation.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/backends/gitlab/implementation.js b/src/backends/gitlab/implementation.js index 440abc82efe4..721be4d35217 100644 --- a/src/backends/gitlab/implementation.js +++ b/src/backends/gitlab/implementation.js @@ -99,13 +99,15 @@ export default class GitLab { sem.take(() => this.api.readFile(file.path, file.id).then((data) => { resolve({ file, data }); sem.leave(); - }).catch((err) => { + }).catch((error = true) => { sem.leave(); - reject(err); + console.error(`failed to load file from GitLab: ${ file.path }`); + resolve({ error }); })) ))); }); - return Promise.all(promises); + return Promise.all(promises) + .then(loadedEntries => loadedEntries.filter(loadedEntry => !loadedEntry.error)); }; // Fetches a single entry. From 0fb3ebffd395341946f2f2d01bfe84d552089299 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Fri, 8 Jun 2018 15:00:13 -0700 Subject: [PATCH 39/45] Update default git-gateway endpoint --- src/backends/git-gateway/implementation.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/backends/git-gateway/implementation.js b/src/backends/git-gateway/implementation.js index 0eec62b494a5..f8797c3d54b1 100644 --- a/src/backends/git-gateway/implementation.js +++ b/src/backends/git-gateway/implementation.js @@ -11,12 +11,12 @@ import AuthenticationPage from "./AuthenticationPage"; const localHosts = { localhost: true, '127.0.0.1': true, - '0.0.0.0': true -} + '0.0.0.0': true, +}; const defaults = { identity: '/.netlify/identity', - gateway: '/.netlify/git/github' -} + gateway: '/.netlify/git', +}; function getEndpoint(endpoint, netlifySiteURL) { if (localHosts[document.location.host.split(":").shift()] && netlifySiteURL && endpoint.match(/^\/\.netlify\//)) { From c4c403e8ecce3268f8fe532944d70ab7a8bacb75 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Fri, 8 Jun 2018 15:00:48 -0700 Subject: [PATCH 40/45] Fix instantiation of proxied backend implementation classes --- src/backends/github/implementation.js | 2 +- src/backends/gitlab/implementation.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index 92af8b4cccf0..45653c0fe699 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -6,7 +6,7 @@ import API from "./API"; const MAX_CONCURRENT_DOWNLOADS = 10; export default class GitHub { - constructor(config, options) { + constructor(config, options={}) { this.config = config; this.options = { proxied: false, diff --git a/src/backends/gitlab/implementation.js b/src/backends/gitlab/implementation.js index 721be4d35217..cb9222e8166a 100644 --- a/src/backends/gitlab/implementation.js +++ b/src/backends/gitlab/implementation.js @@ -9,7 +9,7 @@ import { EDITORIAL_WORKFLOW } from "Constants/publishModes"; const MAX_CONCURRENT_DOWNLOADS = 10; export default class GitLab { - constructor(config, options) { + constructor(config, options={}) { this.config = config; this.options = { proxied: false, @@ -21,7 +21,7 @@ export default class GitLab { throw new Error("The GitLab backend does not support the Editorial Workflow.") } - if (!options.proxied && config.getIn(["backend", "repo"]) == null) { + if (!this.options.proxied && config.getIn(["backend", "repo"]) == null) { throw new Error("The GitLab backend needs a \"repo\" in the backend configuration."); } From 16ebe26f8b0f3a793183feeff6ccce04e0ada469 Mon Sep 17 00:00:00 2001 From: Caleb Date: Thu, 7 Jun 2018 18:27:53 -0600 Subject: [PATCH 41/45] Add missing `auth_endpoint` to GitLab's Netlify auth. --- src/backends/gitlab/AuthenticationPage.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/backends/gitlab/AuthenticationPage.js b/src/backends/gitlab/AuthenticationPage.js index 949dfd3d070f..edcf21348676 100644 --- a/src/backends/gitlab/AuthenticationPage.js +++ b/src/backends/gitlab/AuthenticationPage.js @@ -30,7 +30,8 @@ export default class AuthenticationPage extends React.Component { } else { this.auth = new NetlifyAuthenticator({ base_url: this.props.base_url, - site_id: (document.location.host.split(':')[0] === 'localhost') ? 'cms.netlify.com' : this.props.siteId + site_id: (document.location.host.split(':')[0] === 'localhost') ? 'cms.netlify.com' : this.props.siteId, + auth_endpoint: this.props.authEndpoint, }); } } From 3678e5dca84693e49c42bf659045d9086199025f Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Sun, 10 Jun 2018 15:42:40 -0700 Subject: [PATCH 42/45] Remove console.logs --- src/backends/git-gateway/GitLabAPI.js | 2 +- src/backends/git-gateway/implementation.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/backends/git-gateway/GitLabAPI.js b/src/backends/git-gateway/GitLabAPI.js index b504f24bac53..0d21ca067372 100644 --- a/src/backends/git-gateway/GitLabAPI.js +++ b/src/backends/git-gateway/GitLabAPI.js @@ -11,7 +11,7 @@ export default class API extends GitlabAPI { this.repoURL = ""; } - authenticateRequest = async req => console.log("authenticateRequest", req.toJS()) || unsentRequest.withHeaders({ + authenticateRequest = async req => unsentRequest.withHeaders({ Authorization: `Bearer ${ await this.tokenPromise() }`, }, req); diff --git a/src/backends/git-gateway/implementation.js b/src/backends/git-gateway/implementation.js index f8797c3d54b1..9933b50639b8 100644 --- a/src/backends/git-gateway/implementation.js +++ b/src/backends/git-gateway/implementation.js @@ -46,7 +46,6 @@ export default class GitGateway { if (backendTypeMatches) { this.backendType = backendTypeMatches[1]; this.gatewayUrl = this.gatewayUrl.replace(backendTypeRegex, "/"); - console.log({ backendType: this.backendType, gatewayUrl: this.gatewayUrl }); } else { this.backendType = null; } From c92e39ea75f0aea8cfa511caee73a362e8b3fa92 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Mon, 11 Jun 2018 14:14:41 -0700 Subject: [PATCH 43/45] Fix integrations pagination --- src/actions/entries.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/actions/entries.js b/src/actions/entries.js index 38896b4eaea5..3f5e14490b54 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -275,7 +275,7 @@ export function loadEntries(collection, page = 0) { // pagination API. Other backends will simply store an empty // cursor, which behaves identically to no cursor at all. cursor: integration - ? Cursor.create({ meta: { usingOldPaginationAPI: true }, data: { nextPage: page + 1 } }) + ? Cursor.create({ actions: ["next"], meta: { usingOldPaginationAPI: true }, data: { nextPage: page + 1 } }) : Cursor.create(response.cursor), })) .then(response => dispatch(entriesLoaded( @@ -321,8 +321,7 @@ export function traverseCollectionCursor(collection, action) { // Handle cursors representing pages in the old, integer-based // pagination API if (cursor.meta.get("usingOldPaginationAPI", false)) { - const [data] = cursor.unwrapData(); - return loadEntries(collection, data.get("nextPage")); + return dispatch(loadEntries(collection, cursor.data.get("nextPage"))); } try { From 117e8b16b4cbee60a2c88270aff7c5a3a84b3eb4 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Mon, 11 Jun 2018 16:07:47 -0700 Subject: [PATCH 44/45] Change auth_url to base_url+auth_endpoint for GitLab implicit auth --- src/backends/gitlab/AuthenticationPage.js | 3 ++- src/lib/implicit-oauth.js | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/backends/gitlab/AuthenticationPage.js b/src/backends/gitlab/AuthenticationPage.js index edcf21348676..7769911e5fb6 100644 --- a/src/backends/gitlab/AuthenticationPage.js +++ b/src/backends/gitlab/AuthenticationPage.js @@ -16,7 +16,8 @@ export default class AuthenticationPage extends React.Component { const authType = this.props.config.getIn(['backend', 'auth_type']); if (authType === "implicit") { this.auth = new ImplicitAuthenticator({ - auth_url: this.props.config.getIn(['backend', 'auth_url'], 'https://gitlab.com/oauth/authorize'), + base_url: this.props.config.getIn(['backend', 'base_url'], "https://gitlab.com"), + auth_endpoint: this.props.config.getIn(['backend', 'auth_endpoint'], 'oauth/authorize'), appID: this.props.config.getIn(['backend', 'app_id']), }); // Complete implicit authentication if we were redirected back to from the provider. diff --git a/src/lib/implicit-oauth.js b/src/lib/implicit-oauth.js index 2970a559f291..cf5f2a0a3c4f 100644 --- a/src/lib/implicit-oauth.js +++ b/src/lib/implicit-oauth.js @@ -1,4 +1,5 @@ import { Map } from 'immutable'; +import { trim, trimEnd } from 'lodash'; import { randomStr } from 'Lib/randomGenerator'; import history from 'Routing/history'; @@ -17,7 +18,9 @@ function validateNonce(check) { export default class ImplicitAuthenticator { constructor(config = {}) { - this.auth_url = config.auth_url; + const baseURL = trimEnd(config.base_url, '/'); + const authEndpoint = trim(config.auth_endpoint, '/'); + this.auth_url = `${ baseURL }/${ authEndpoint }`; this.appID = config.app_id; } From d86c61025e490d12c6985955ca20d9638e0dec43 Mon Sep 17 00:00:00 2001 From: Benaiah Mischenko Date: Mon, 11 Jun 2018 17:35:51 -0700 Subject: [PATCH 45/45] Fix querying through integrations --- src/actions/search.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/actions/search.js b/src/actions/search.js index 6a9fe59024bd..6f08608014e1 100644 --- a/src/actions/search.js +++ b/src/actions/search.js @@ -128,12 +128,12 @@ export function searchEntries(searchTerm, page = 0) { // in specific collections and return raw data (no entries). export function query(namespace, collectionName, searchFields, searchTerm) { return (dispatch, getState) => { - dispatch(querying(namespace, collection, searchFields, searchTerm)); + dispatch(querying(namespace, collectionName, searchFields, searchTerm)); const state = getState(); const backend = currentBackend(state.config); + const integration = selectIntegration(state, collectionName, 'search'); const collection = state.collections.find(collection => collection.get('name') === collectionName); - const integration = selectIntegration(state, collection, 'search'); const queryPromise = integration ? getIntegrationProvider(state.integrations, backend.getToken, integration)