migrate bitbucket backend

This commit is contained in:
Shawn Erquhart 2018-07-25 06:56:53 -04:00
parent faab1e38ba
commit aac5167461
10 changed files with 480 additions and 4 deletions

View File

@ -0,0 +1,3 @@
const config = require('../../babel.config.js');
module.exports = config;

View File

@ -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"
}
}

View File

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

View File

@ -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 (
<AuthenticationPage
onLogin={this.handleLogin}
loginDisabled={this.props.inProgress}
loginErrorMessage={this.state.loginError}
renderButtonContent={() => (
<React.Fragment>
<LoginButtonIcon type="bitbucket"/>
{inProgress ? "Logging in..." : "Login with Bitbucket"}
</React.Fragment>
)}
/>
);
}
}

View File

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

View File

@ -0,0 +1,4 @@
export BitbucketBackend from './implementation';
export API from './API';
export AuthenticationPage from './AuthenticationPage';

View File

@ -0,0 +1 @@
module.exports = require('../../webpack.config.js');

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 757 B

After

Width:  |  Height:  |  Size: 757 B