diff --git a/src/backends/backend.js b/src/backends/backend.js index db6f5388..bde9ec1e 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -18,6 +18,7 @@ import { sanitizeSlug } from "Lib/urlHelper"; import TestRepoBackend from "./test-repo/implementation"; import GitHubBackend from "./github/implementation"; import GitLabBackend from "./gitlab/implementation"; +import BitBucketBackend from "./bitbucket/implementation"; import GitGatewayBackend from "./git-gateway/implementation"; import { registerBackend, getBackend } from 'Lib/registry'; import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from '../valueObjects/Cursor'; @@ -28,6 +29,7 @@ import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from '../valueObjects/Cursor'; registerBackend('git-gateway', GitGatewayBackend); registerBackend('github', GitHubBackend); registerBackend('gitlab', GitLabBackend); +registerBackend('bitbucket', BitBucketBackend); registerBackend('test-repo', TestRepoBackend); @@ -124,8 +126,8 @@ const sortByScore = (a, b) => { }; class Backend { - constructor(implementation, backendName, authStore = null) { - this.implementation = implementation; + constructor(implementation, { authStore = null, backendName, config } = {}) { + this.implementation = implementation.init(config, { updateUserCredentials: this.updateUserCredentials }); this.backendName = backendName; this.authStore = authStore; if (this.implementation === null) { @@ -147,6 +149,15 @@ class Backend { return Promise.resolve(null); } + updateUserCredentials = updatedCredentials => { + const storedUser = this.authStore && this.authStore.retrieve(); + if (storedUser && storedUser.backendName === this.backendName) { + const newUser = { ...storedUser, ...updatedCredentials }; + this.authStore.store(newUser); + return newUser; + } + }; + authComponent() { return this.implementation.authComponent(); } @@ -476,7 +487,7 @@ export function resolveBackend(config) { if (!getBackend(name)) { throw new Error(`Backend not found: ${ name }`); } else { - return new Backend(getBackend(name).init(config), name, authStore); + return new Backend(getBackend(name), { backendName: name, authStore, config }); } } diff --git a/src/backends/bitbucket/API.js b/src/backends/bitbucket/API.js new file mode 100644 index 00000000..ccbb3541 --- /dev/null +++ b/src/backends/bitbucket/API.js @@ -0,0 +1,149 @@ +import { flow } from "lodash"; +import LocalForage from "Lib/LocalForage"; +import unsentRequest from "Lib/unsentRequest"; +import { responseParser } from "Lib/backendHelper"; +import { then } from "Lib/promiseHelper"; +import { basename } from "Lib/pathHelper"; +import AssetProxy from "ValueObjects/AssetProxy"; +import Cursor from "ValueObjects/Cursor"; +import { APIError } from "ValueObjects/errors"; + +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; + this.repoURL = `/repositories/${ this.repo }`; + 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 (item instanceof AssetProxy ? 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/src/backends/bitbucket/AuthenticationPage.js b/src/backends/bitbucket/AuthenticationPage.js new file mode 100644 index 00000000..bce564a4 --- /dev/null +++ b/src/backends/bitbucket/AuthenticationPage.js @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Authenticator from 'Lib/netlify-auth'; +import { Icon } from 'UI'; + +export default class AuthenticationPage 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 Authenticator(cfg); + + auth.authenticate({ provider: 'bitbucket', scope: 'repo' }, (err, data) => { + if (err) { + this.setState({ loginError: err.toString() }); + return; + } + this.props.onLogin(data); + }); + }; + + render() { + const { loginError } = this.state; + const { inProgress } = this.props; + + return ( +
+ + {loginError &&

{loginError}

} + +
+ ); + } +} diff --git a/src/backends/bitbucket/implementation.js b/src/backends/bitbucket/implementation.js new file mode 100644 index 00000000..6933dbab --- /dev/null +++ b/src/backends/bitbucket/implementation.js @@ -0,0 +1,222 @@ +import semaphore from "semaphore"; +import { flow, trimStart } from "lodash"; +import { EDITORIAL_WORKFLOW } from "Constants/publishModes"; +import { CURSOR_COMPATIBILITY_SYMBOL } from "ValueObjects/Cursor"; +import { filterByPropExtension } from "Lib/backendHelper"; +import { resolvePromiseProperties, then } from "Lib/promiseHelper"; +import unsentRequest from "Lib/unsentRequest"; +import AuthenticationPage from "./AuthenticationPage"; +import Authenticator from 'Lib/netlify-auth'; +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 (config.getIn(["publish_mode"]) === EDITORIAL_WORKFLOW) { + 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/src/components/UI/Icon/images/_index.js b/src/components/UI/Icon/images/_index.js index 352280f4..8d46b6ec 100644 --- a/src/components/UI/Icon/images/_index.js +++ b/src/components/UI/Icon/images/_index.js @@ -1,6 +1,7 @@ import iconAdd from './add.svg'; import iconAddWith from './add-with.svg'; import iconArrow from './arrow.svg'; +import iconBitbucket from './bitbucket.svg'; import iconBold from './bold.svg'; import iconCheck from './check.svg'; import iconChevron from './chevron.svg'; @@ -44,6 +45,7 @@ const images = { 'add': iconAdd, 'add-with': iconAddWith, 'arrow': iconArrow, + 'bitbucket': iconBitbucket, 'bold': iconBold, 'check': iconCheck, 'chevron': iconChevron, diff --git a/src/components/UI/Icon/images/bitbucket.svg b/src/components/UI/Icon/images/bitbucket.svg new file mode 100644 index 00000000..cd8a5d76 --- /dev/null +++ b/src/components/UI/Icon/images/bitbucket.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/lib/backendHelper.js b/src/lib/backendHelper.js new file mode 100644 index 00000000..5d69a1a2 --- /dev/null +++ b/src/lib/backendHelper.js @@ -0,0 +1,44 @@ +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"; + +export const filterByPropExtension = (extension, propName) => arr => + arr.filter(el => fileExtension(get(el, propName)) === extension); + +const catchFormatErrors = (format, formatter) => res => { + try { + return formatter(res); + } catch (err) { + throw new Error(`Response cannot be parsed into the expected format (${ format }): ${ err.message }`); + } +}; + +const responseFormatters = fromJS({ + json: async res => { + const contentType = res.headers.get("Content-Type"); + if (!contentType.startsWith("application/json") && !contentType.startsWith("text/json")) { + throw new Error(`${ contentType } is not a valid JSON Content-Type`); + } + return res.json(); + }, + text: async res => res.text(), + blob: async res => res.blob(), +}).mapEntries( + ([format, formatter]) => [format, catchFormatErrors(format, formatter)] +); + +export const parseResponse = async (res, { expectingOk = true, format = "text" } = {}) => { + if (expectingOk && !res.ok) { + throw new Error(`Expected an ok response, but received an error status: ${ res.status }.`); + } + const formatter = responseFormatters.get(format, false); + if (!formatter) { + throw new Error(`${ format } is not a supported response format.`); + } + const body = await formatter(res); + return body; +}; + +export const responseParser = options => res => parseResponse(res, options); diff --git a/src/lib/netlify-auth.js b/src/lib/netlify-auth.js index 3d766f44..4d43a5ee 100644 --- a/src/lib/netlify-auth.js +++ b/src/lib/netlify-auth.js @@ -51,17 +51,17 @@ class Authenticator { authorizeCallback(options, cb) { const fn = (e) => { - var data, err; if (e.origin !== this.base_url) { return; } + if (e.data.indexOf('authorization:' + options.provider + ':success:') === 0) { - data = JSON.parse(e.data.match(new RegExp('^authorization:' + options.provider + ':success:(.+)$'))[1]); + const data = JSON.parse(e.data.match(new RegExp('^authorization:' + options.provider + ':success:(.+)$'))[1]); window.removeEventListener('message', fn, false); this.authWindow.close(); cb(null, data); } if (e.data.indexOf('authorization:' + options.provider + ':error:') === 0) { console.log('Got authorization error'); - err = JSON.parse(e.data.match(new RegExp('^authorization:' + options.provider + ':error:(.+)$'))[1]); + const err = JSON.parse(e.data.match(new RegExp('^authorization:' + options.provider + ':error:(.+)$'))[1]); window.removeEventListener('message', fn, false); this.authWindow.close(); cb(new NetlifyError(err)); @@ -75,29 +75,29 @@ class Authenticator { return this.site_id; } const host = document.location.host.split(':')[0]; - return host === 'localhost' ? null : host; + return host === 'localhost' ? 'cms.netlify.com' : host; } authenticate(options, cb) { - var left, top, url, - siteID = this.getSiteID(), - provider = options.provider; + const { provider } = options; + const siteID = this.getSiteID(); + if (!provider) { return cb(new NetlifyError({ - message: 'You must specify a provider when calling netlify.authenticate' + message: 'You must specify a provider when calling netlify.authenticate', })); } if (!siteID) { return cb(new NetlifyError({ - message: 'You must set a site_id with netlify.configure({site_id: \'your-site-id\'}) to make authentication work from localhost' + message: 'You must set a site_id with netlify.configure({site_id: \'your-site-id\'}) to make authentication work from localhost', })); } const conf = PROVIDERS[provider] || PROVIDERS.github; - left = (screen.width / 2) - (conf.width / 2); - top = (screen.height / 2) - (conf.height / 2); + const left = (screen.width / 2) - (conf.width / 2); + const top = (screen.height / 2) - (conf.height / 2); window.addEventListener('message', this.handshakeCallback(options, cb), false); - url = `${this.base_url}/${this.auth_endpoint}?provider=${options.provider}&site_id=${siteID}`; + let url = `${ this.base_url }/${ this.auth_endpoint }?provider=${ options.provider }&site_id=${ siteID }`; if (options.scope) { url += '&scope=' + options.scope; } @@ -118,6 +118,33 @@ class Authenticator { ); this.authWindow.focus(); } + + refresh(options, cb) { + const { provider, refresh_token } = options; + const siteID = this.getSiteID(); + const onError = cb || Promise.reject.bind(Promise); + + if (!provider || !refresh_token) { + return onError(new NetlifyError({ + message: 'You must specify a provider and refresh token when calling netlify.refresh', + })); + } + if (!siteID) { + return onError(new NetlifyError({ + message: 'You must set a site_id with netlify.configure({site_id: \'your-site-id\'}) to make token refresh work from localhost', + })); + } + const url = `${ this.base_url }/${ this.auth_endpoint }/refresh?provider=${ provider }&site_id=${ siteID }&refresh_token=${ refresh_token }`; + const refreshPromise = fetch(url, { method: "POST", body: "" }).then(res => res.json()); + + // Return a promise if a callback wasn't provided + if (!cb) { + return refreshPromise; + } + + // Otherwise, use the provided callback. + refreshPromise.then(data => cb(null, data)).catch(cb); + } } export default Authenticator; diff --git a/src/lib/registry.js b/src/lib/registry.js index 6679636d..ad998e0f 100644 --- a/src/lib/registry.js +++ b/src/lib/registry.js @@ -104,7 +104,7 @@ export function registerBackend(name, BackendClass) { console.error(`Backend [${ name }] already registered. Please choose a different name.`); } else { registry.backends[name] = { - init: config => new BackendClass(config), + init: (...args) => new BackendClass(...args), }; } } diff --git a/src/lib/unsentRequest.js b/src/lib/unsentRequest.js index 19f3cec7..2abffba2 100644 --- a/src/lib/unsentRequest.js +++ b/src/lib/unsentRequest.js @@ -44,7 +44,7 @@ const getPropMergeFunctions = path => [ ]; const [withMethod, withDefaultMethod] = getPropSetFunctions(["method"]); -const [withBody, withDefaultBody] = getPropSetFunctions(["method"]); +const [withBody, withDefaultBody] = getPropSetFunctions(["body"]); const [withParams, withDefaultParams] = getPropMergeFunctions(["params"]); const [withHeaders, withDefaultHeaders] = getPropMergeFunctions(["headers"]);