diff --git a/packages/netlify-cms-backend-bitbucket/babel.config.js b/packages/netlify-cms-backend-bitbucket/babel.config.js new file mode 100644 index 00000000..8bb1d682 --- /dev/null +++ b/packages/netlify-cms-backend-bitbucket/babel.config.js @@ -0,0 +1,3 @@ +const config = require('../../babel.config.js'); + +module.exports = config; diff --git a/packages/netlify-cms-backend-bitbucket/package.json b/packages/netlify-cms-backend-bitbucket/package.json new file mode 100644 index 00000000..09c8a088 --- /dev/null +++ b/packages/netlify-cms-backend-bitbucket/package.json @@ -0,0 +1,41 @@ +{ + "name": "netlify-cms-backend-bitbucket", + "description": "Bitbucket backend for Netlify CMS", + "version": "2.0.0-alpha.0", + "license": "MIT", + "main": "dist/netlify-cms-backend-bitbucket.js", + "keywords": [ + "netlify", + "netlify-cms", + "backend", + "bitbucket" + ], + "sideEffects": false, + "scripts": { + "watch": "webpack -w", + "build": "cross-env NODE_ENV=production webpack" + }, + "dependencies": { + "js-base64": "^2.4.8", + "semaphore": "^1.1.0" + }, + "devDependencies": { + "@babel/cli": "^7.0.0-beta.54", + "@babel/core": "^7.0.0-beta.54", + "cross-env": "^5.2.0", + "rollup": "^0.63.2", + "rollup-plugin-babel": "^4.0.0-beta.7", + "webpack": "^4.16.1", + "webpack-cli": "^3.1.0" + }, + "peerDependencies": { + "immutable": "^3.7.6", + "lodash": "^4.17.10", + "netlify-cms-lib-auth": "2.0.0-alpha.0", + "netlify-cms-lib-util": "2.0.0-alpha.0", + "netlify-cms-ui-default": "2.0.0-alpha.0", + "prop-types": "^15.6.2", + "react": "^16.4.1", + "react-emotion": "^9.2.6" + } +} diff --git a/packages/netlify-cms-backend-bitbucket/src/API.js b/packages/netlify-cms-backend-bitbucket/src/API.js new file mode 100644 index 00000000..a97f828d --- /dev/null +++ b/packages/netlify-cms-backend-bitbucket/src/API.js @@ -0,0 +1,151 @@ +import { flow } from "lodash"; +import { + LocalForage, + unsentRequest, + responseParser, + then, + basename, + Cursor, + APIError +} from "netlify-cms-lib-util"; + +export default class API { + constructor(config) { + this.api_root = config.api_root || "https://api.bitbucket.org/2.0"; + this.branch = config.branch || "master"; + this.repo = config.repo || ""; + this.requestFunction = config.requestFunction || unsentRequest.performRequest; + // Allow overriding this.hasWriteAccess + this.hasWriteAccess = config.hasWriteAccess || this.hasWriteAccess; + this.repoURL = this.repo ? `/repositories/${ this.repo }` : ""; + } + + buildRequest = req => flow([ + unsentRequest.withRoot(this.api_root), + unsentRequest.withTimestamp, + ])(req); + + request = req => flow([ + this.buildRequest, + this.requestFunction, + p => p.catch(err => Promise.reject(new APIError(err.message, null, "BitBucket"))), + ])(req); + + requestJSON = req => flow([ + unsentRequest.withDefaultHeaders({ "Content-Type": "application/json" }), + this.request, + then(responseParser({ format: "json" })), + p => p.catch(err => Promise.reject(new APIError(err.message, null, "BitBucket"))), + ])(req); + requestText = req => flow([ + unsentRequest.withDefaultHeaders({ "Content-Type": "text/plain" }), + this.request, + then(responseParser({ format: "text" })), + p => p.catch(err => Promise.reject(new APIError(err.message, null, "BitBucket"))), + ])(req); + + user = () => this.request("/user"); + hasWriteAccess = user => this.request(this.repoURL).then(res => res.ok); + + isFile = ({ type }) => type === "commit_file"; + processFile = file => ({ + ...file, + name: basename(file.path), + download_url: file.links.self.href, + + // BitBucket does not return file SHAs, but it does give us the + // commit SHA. Since the commit SHA will change if any files do, + // we can construct an ID using the commit SHA and the file path + // that will help with caching (though not as well as a normal + // SHA, since it will change even if the individual file itself + // doesn't.) + ...(file.commit && file.commit.hash + ? { id: `${ file.commit.hash }/${ file.path }` } + : {}), + }); + processFiles = files => files.filter(this.isFile).map(this.processFile); + + readFile = async (path, sha, { ref = this.branch, parseText = true } = {}) => { + const cacheKey = parseText ? `bb.${ sha }` : `bb.${ sha }.blob`; + const cachedFile = sha ? await LocalForage.getItem(cacheKey) : null; + if (cachedFile) { return cachedFile; } + const result = await this.request({ + url: `${ this.repoURL }/src/${ ref }/${ path }`, + cache: "no-store", + }).then(parseText ? responseParser({ format: "text" }) : responseParser({ format: "blob" })); + if (sha) { LocalForage.setItem(cacheKey, result); } + return result; + } + + getEntriesAndCursor = jsonResponse => { + const { size: count, page: index, pagelen: pageSize, next, previous: prev, values: entries } = jsonResponse; + const pageCount = (pageSize && count) ? Math.ceil(count / pageSize) : undefined; + return { + entries, + cursor: Cursor.create({ + actions: [...(next ? ["next"] : []), ...(prev ? ["prev"] : [])], + meta: { index, count, pageSize, pageCount }, + data: { links: { next, prev } }, + }), + }; + } + + listFiles = async path => { + const { entries, cursor } = await flow([ + // sort files by filename ascending + unsentRequest.withParams({ sort: "-path" }), + this.requestJSON, + then(this.getEntriesAndCursor), + ])(`${ this.repoURL }/src/${ this.branch }/${ path }`); + return { entries: this.processFiles(entries), cursor }; + } + + traverseCursor = async (cursor, action) => flow([ + this.requestJSON, + then(this.getEntriesAndCursor), + then(({ cursor: newCursor, entries }) => ({ cursor: newCursor, entries: this.processFiles(entries) })), + ])(cursor.data.getIn(["links", action])); + + listAllFiles = async path => { + const { cursor: initialCursor, entries: initialEntries } = await this.listFiles(path); + const entries = [...initialEntries]; + let currentCursor = initialCursor; + while (currentCursor && currentCursor.actions.has("next")) { + const { cursor: newCursor, entries: newEntries } = await this.traverseCursor(currentCursor, "next"); + entries.push(...newEntries); + currentCursor = newCursor; + } + return this.processFiles(entries); + }; + + uploadBlob = async item => { + const contentBase64 = await (has(item, 'toBase64') ? item.toBase64() : Promise.resolve(item.raw)); + const formData = new FormData(); + formData.append(item.path, contentBase64); + + return flow([ + unsentRequest.withMethod("POST"), + unsentRequest.withBody(formData), + this.request, + then(() => ({ ...item, uploaded: true })), + ])(`${ this.repoURL }/src`); + }; + + persistFiles = (files, { commitMessage, newEntry }) => Promise.all( + files.filter(({ uploaded }) => !uploaded).map(this.uploadBlob) + ); + + deleteFile = (path, message, options={}) => { + const branch = options.branch || this.branch; + const body = new FormData(); + body.append('files', path); + if (message && message !== "") { + body.append("message", message); + } + return flow([ + unsentRequest.withMethod("POST"), + unsentRequest.withBody(body), + this.request, + ])(`${ this.repoURL }/src`); + }; +} diff --git a/packages/netlify-cms-backend-bitbucket/src/AuthenticationPage.js b/packages/netlify-cms-backend-bitbucket/src/AuthenticationPage.js new file mode 100644 index 00000000..5bd915ab --- /dev/null +++ b/packages/netlify-cms-backend-bitbucket/src/AuthenticationPage.js @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styled from 'react-emotion'; +import { NetlifyAuthenticator } from 'netlify-cms-lib-auth'; +import { AuthenticationPage, Icon } from 'netlify-cms-ui-default'; + +const LoginButtonIcon = styled(Icon)` + margin-right: 18px; +` + +export default class BitbucketAuthenticationPage extends React.Component { + static propTypes = { + onLogin: PropTypes.func.isRequired, + inProgress: PropTypes.bool, + }; + + 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.site_id, + auth_endpoint: this.props.authEndpoint, + }; + const auth = new NetlifyAuthenticator(cfg); + + auth.authenticate({ provider: 'bitbucket', scope: 'repo' }, (err, data) => { + if (err) { + this.setState({ loginError: err.toString() }); + return; + } + this.props.onLogin(data); + }); + }; + + render() { + return ( + ( + + + {inProgress ? "Logging in..." : "Login with Bitbucket"} + + )} + /> + ); + } +} diff --git a/packages/netlify-cms-backend-bitbucket/src/implementation.js b/packages/netlify-cms-backend-bitbucket/src/implementation.js new file mode 100644 index 00000000..40ba7938 --- /dev/null +++ b/packages/netlify-cms-backend-bitbucket/src/implementation.js @@ -0,0 +1,224 @@ +import semaphore from "semaphore"; +import { flow, trimStart } from "lodash"; +import { + CURSOR_COMPATIBILITY_SYMBOL, + filterByPropExtension, + resolvePromiseProperties, + then, + unsentRequest, +} from "netlify-cms-lib-util"; +import { NetlifyAuthenticator } from 'netlify-cms-lib-auth'; +import AuthenticationPage from "./AuthenticationPage"; +import API from "./API"; + +const MAX_CONCURRENT_DOWNLOADS = 10; + +// Implementation wrapper class +export default class Bitbucket { + constructor(config, options={}) { + this.config = config; + this.options = { + proxied: false, + API: null, + updateUserCredentials: async () => null, + ...options, + }; + + if (this.options.useWorkflow) { + throw new Error("The BitBucket backend does not support the Editorial Workflow."); + } + + if (!this.options.proxied && !config.getIn(["backend", "repo"], false)) { + throw new Error("The BitBucket backend needs a \"repo\ in the backend configuration."); + } + + this.api = this.options.API || null; + + this.updateUserCredentials = this.options.updateUserCredentials; + + this.repo = config.getIn(["backend", "repo"], ""); + this.branch = config.getIn(["backend", "branch"], "master"); + this.api_root = config.getIn(["backend", "api_root"], "https://api.bitbucket.org/2.0"); + this.base_url = config.get("base_url"); + this.site_id = config.get("site_id"); + this.token = ""; + } + + authComponent() { + return AuthenticationPage; + } + + setUser(user) { + this.token = user.token; + this.api = new API({ requestFunction: this.apiRequestFunction, branch: this.branch, repo: this.repo }); + } + + restoreUser(user) { + return this.authenticate(user); + } + + authenticate(state) { + this.token = state.token; + this.refreshToken = state.refresh_token; + this.api = new API({ requestFunction: this.apiRequestFunction, branch: this.branch, repo: this.repo, api_root: this.api_root }); + + return this.api.user().then(user => + this.api.hasWriteAccess(user).then(isCollab => { + if (!isCollab) throw new Error("Your BitBucker user account does not have access to this repo."); + return Object.assign({}, user, { token: state.token, refresh_token: state.refresh_token }); + }) + ); + } + + getRefreshedAccessToken() { + if (this.refreshedTokenPromise) { + return this.refreshedTokenPromise; + } + + // instantiating a new Authenticator on each refresh isn't ideal, + if (!this.auth) { + const cfg = { + base_url: this.base_url, + site_id: this.site_id, + }; + this.authenticator = new Authenticator(cfg); + } + + this.refreshedTokenPromise = this.authenticator.refresh({ provider: "bitbucket", refresh_token: this.refreshToken }) + .then(({ token, refresh_token }) => { + this.token = token; + this.refreshToken = refresh_token; + this.refreshedTokenPromise = undefined; + this.updateUserCredentials({ token, refresh_token }); + return token; + }); + + return this.refreshedTokenPromise; + } + + logout() { + this.token = null; + return; + } + + getToken() { + if (this.refreshedTokenPromise) { + return this.refreshedTokenPromise; + } + + return Promise.resolve(this.token); + } + + apiRequestFunction = async req => { + const token = this.refreshedTokenPromise ? await this.refreshedTokenPromise : this.token; + return flow([ + unsentRequest.withHeaders({ Authorization: `Bearer ${ token }` }), + unsentRequest.performRequest, + then(async res => { + if (res.status === 401) { + const json = (await res.json().catch(() => null)); + if (json && json.type === "error" && /^access token expired/i.test(json.error.message)) { + const newToken = await this.getRefreshedAccessToken(); + const reqWithNewToken = unsentRequest.withHeaders({ Authorization: `Bearer ${ newToken }` }, req); + return unsentRequest.performRequest(reqWithNewToken); + } + } + return res; + }), + ])(req); + }; + + entriesByFolder(collection, extension) { + const listPromise = this.api.listFiles(collection.get("folder")); + return resolvePromiseProperties({ + files: listPromise + .then(({ entries }) => entries) + .then(filterByPropExtension(extension, "path")) + .then(this.fetchFiles), + cursor: listPromise.then(({ cursor }) => cursor), + }).then(({ files, cursor }) => { + files[CURSOR_COMPATIBILITY_SYMBOL] = cursor; + return files; + }); + } + + allEntriesByFolder(collection, extension) { + return this.api.listAllFiles(collection.get("folder")) + .then(filterByPropExtension(extension, "path")) + .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.id).then((data) => { + resolve({ file, data }); + sem.leave(); + }).catch((error = true) => { + sem.leave(); + console.error(`failed to load file from BitBucket: ${ file.path }`); + resolve({ error }); + })) + ))); + }); + return Promise.all(promises) + .then(loadedEntries => loadedEntries.filter(loadedEntry => !loadedEntry.error)); + } + + getEntry(collection, slug, path) { + return this.api.readFile(path).then(data => ({ + file: { path }, + data, + })); + } + + getMedia() { + const sem = semaphore(MAX_CONCURRENT_DOWNLOADS); + + return this.api.listAllFiles(this.config.get("media_folder")) + .then(files => files.map(({ id, name, download_url, path }) => { + const getBlobPromise = () => new Promise((resolve, reject) => + sem.take(() => + this.api.readFile(path, id, { parseText: false }) + .then(resolve, reject) + .finally(() => sem.leave()) + ) + ); + + return { id, name, getBlobPromise, url: download_url, path }; + })); + } + + persistEntry(entry, mediaFiles, options = {}) { + return this.api.persistFiles([entry], options); + } + + async persistMedia(mediaFile, options = {}) { + await this.api.persistFiles([mediaFile], options); + const { value, path, fileObj } = mediaFile; + const getBlobPromise = () => Promise.resolve(fileObj); + return { name: value, size: fileObj.size, getBlobPromise, path: trimStart(path, '/k') }; + } + + 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, + })); + } +} diff --git a/packages/netlify-cms-backend-bitbucket/src/index.js b/packages/netlify-cms-backend-bitbucket/src/index.js new file mode 100644 index 00000000..fdad0123 --- /dev/null +++ b/packages/netlify-cms-backend-bitbucket/src/index.js @@ -0,0 +1,4 @@ +export BitbucketBackend from './implementation'; +export API from './API'; +export AuthenticationPage from './AuthenticationPage'; + diff --git a/packages/netlify-cms-backend-bitbucket/webpack.config.js b/packages/netlify-cms-backend-bitbucket/webpack.config.js new file mode 100644 index 00000000..52d79f92 --- /dev/null +++ b/packages/netlify-cms-backend-bitbucket/webpack.config.js @@ -0,0 +1 @@ +module.exports = require('../../webpack.config.js'); diff --git a/packages/netlify-cms-backend-gitlab/src/AuthenticationPage.js b/packages/netlify-cms-backend-gitlab/src/AuthenticationPage.js index 6218fad7..924fed65 100644 --- a/packages/netlify-cms-backend-gitlab/src/AuthenticationPage.js +++ b/packages/netlify-cms-backend-gitlab/src/AuthenticationPage.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import styled from 'react-emotion'; import { NetlifyAuthenticator, ImplicitAuthenticator } from 'netlify-cms-lib-auth'; -import { AuthenticationPage, Icon, buttons, shadows } from 'netlify-cms-ui-default'; +import { AuthenticationPage, Icon } from 'netlify-cms-ui-default'; const LoginButtonIcon = styled(Icon)` margin-right: 18px; diff --git a/src/lib/backendHelper.js b/packages/netlify-cms-lib-util/src/backendUtil.js similarity index 89% rename from src/lib/backendHelper.js rename to packages/netlify-cms-lib-util/src/backendUtil.js index 5d69a1a2..7ff9b069 100644 --- a/src/lib/backendHelper.js +++ b/packages/netlify-cms-lib-util/src/backendUtil.js @@ -1,8 +1,8 @@ import { get } from "lodash"; import { fromJS } from "immutable"; -import { fileExtension } from "Lib/pathHelper"; -import unsentRequest from "Lib/unsentRequest"; -import { CURSOR_COMPATIBILITY_SYMBOL } from "../valueObjects/Cursor"; +import { fileExtension } from "./path"; +import unsentRequest from "./unsentRequest"; +import { CURSOR_COMPATIBILITY_SYMBOL } from "./Cursor"; export const filterByPropExtension = (extension, propName) => arr => arr.filter(el => fileExtension(get(el, propName)) === extension); diff --git a/src/components/UI/Icon/images/bitbucket.svg b/packages/netlify-cms-ui-default/src/Icon/images/bitbucket.svg similarity index 100% rename from src/components/UI/Icon/images/bitbucket.svg rename to packages/netlify-cms-ui-default/src/Icon/images/bitbucket.svg