BitBucket backend

This commit is contained in:
Benaiah Mischenko 2018-07-19 18:11:23 -07:00 committed by Shawn Erquhart
parent 9838673929
commit 9894b3e805
10 changed files with 525 additions and 17 deletions

View File

@ -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 });
}
}

View File

@ -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`);
};
}

View File

@ -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 (
<section className="nc-githubAuthenticationPage-root">
<Icon className="nc-githubAuthenticationPage-logo" size="500px" type="netlify-cms"/>
{loginError && <p>{loginError}</p>}
<button
className="nc-githubAuthenticationPage-button"
disabled={inProgress}
onClick={this.handleLogin}
>
<Icon type="bitbucket" /> {inProgress ? "Logging in..." : "Login with Bitbucket"}
</button>
</section>
);
}
}

View File

@ -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,
}));
}
}

View File

@ -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,

View File

@ -0,0 +1,3 @@
<svg width="26px" height="26px" viewBox="0 0 26 26" version="1.1">
<path d="M2.77580579,3.0000546 C2.58222841,2.99755793 2.39745454,3.08078757 2.27104968,3.2274172 C2.14464483,3.37404683 2.08954809,3.5690671 2.12053915,3.76016391 L4.90214605,20.6463853 C4.97368482,21.0729296 5.34116371,21.38653 5.77365069,21.3901129 L19.1181559,21.3901129 C19.4427702,21.3942909 19.7215068,21.1601522 19.7734225,20.839689 L22.5550294,3.76344024 C22.5860205,3.57234343 22.5309237,3.37732317 22.4045189,3.23069353 C22.278114,3.0840639 22.0933402,3.00083426 21.8997628,3.00333094 L2.77580579,3.0000546 Z M14.488697,15.2043958 L10.2294639,15.2043958 L9.07619457,9.17921905 L15.520742,9.17921905 L14.488697,15.2043958 Z" id="Shape" fill="#2684FF" fill-rule="nonzero"/>
</svg>

After

Width:  |  Height:  |  Size: 757 B

44
src/lib/backendHelper.js Normal file
View File

@ -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);

View File

@ -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;

View File

@ -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),
};
}
}

View File

@ -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"]);