Feat: editorial workflow bitbucket gitlab (#3014)

* refactor: typescript the backends

* feat: support multiple files upload for GitLab and BitBucket

* fix: load entry media files from media folder or UI state

* chore: cleanup log message

* chore: code cleanup

* refactor: typescript the test backend

* refactor: cleanup getEntry unsued variables

* refactor: moved shared backend code to lib util

* chore: rename files to preserve history

* fix: bind readFile method to API classes

* test(e2e): switch to chrome in cypress tests

* refactor: extract common api methods

* refactor: remove most of immutable js usage from backends

* feat(backend-gitlab): initial editorial workflow support

* feat(backend-gitlab): implement missing workflow methods

* chore: fix lint error

* feat(backend-gitlab): support files deletion

* test(e2e): add gitlab cypress tests

* feat(backend-bitbucket): implement missing editorial workflow methods

* test(e2e): add BitBucket backend e2e tests

* build: update node version to 12 on netlify builds

* fix(backend-bitbucket): extract BitBucket avatar url

* test: fix git-gateway AuthenticationPage test

* test(e2e): fix some backend tests

* test(e2e): fix tests

* test(e2e): add git-gateway editorial workflow test

* chore: code cleanup

* test(e2e): revert back to electron

* test(e2e): add non editorial workflow tests

* fix(git-gateway-gitlab): don't call unpublishedEntry in simple workflow

gitlab git-gateway doesn't support editorial workflow APIs yet. This change makes sure not to call them in simple workflow

* refactor(backend-bitbucket): switch to diffstat API instead of raw diff

* chore: fix test

* test(e2e): add more git-gateway tests

* fix: post rebase typescript fixes

* test(e2e): fix tests

* fix: fix parsing of content key and add tests

* refactor: rename test file

* test(unit): add getStatues unit tests

* chore: update cypress

* docs: update beta docs
This commit is contained in:
Erez Rokah
2020-01-15 00:15:14 +02:00
committed by Shawn Erquhart
parent 4ff5bc2ee0
commit 6f221ab3c1
251 changed files with 70910 additions and 15974 deletions

View File

@ -1,5 +1,4 @@
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import React from 'react';
import styled from '@emotion/styled';
import { partial } from 'lodash';
@ -66,6 +65,8 @@ if (window.netlifyIdentity) {
}
export default class GitGatewayAuthenticationPage extends React.Component {
static authClient;
constructor(props) {
super(props);
component = this;
@ -113,7 +114,7 @@ export default class GitGatewayAuthenticationPage extends React.Component {
onLogin: PropTypes.func.isRequired,
inProgress: PropTypes.bool.isRequired,
error: PropTypes.node,
config: ImmutablePropTypes.map,
config: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
};
@ -162,7 +163,7 @@ export default class GitGatewayAuthenticationPage extends React.Component {
if (errors.identity) {
return (
<AuthenticationPage
logoUrl={config.get('logo_url')}
logoUrl={config.logo_url}
onLogin={this.handleIdentity}
renderPageContent={() => (
<a
@ -178,7 +179,7 @@ export default class GitGatewayAuthenticationPage extends React.Component {
} else {
return (
<AuthenticationPage
logoUrl={config.get('logo_url')}
logoUrl={config.logo_url}
onLogin={this.handleIdentity}
renderButtonContent={() => t('auth.loginWithNetlifyIdentity')}
/>
@ -188,8 +189,8 @@ export default class GitGatewayAuthenticationPage extends React.Component {
return (
<AuthenticationPage
logoUrl={config.get('logo_url')}
siteUrl={config.get('site_url')}
logoUrl={config.logo_url}
siteUrl={config.site_url}
renderPageContent={() => (
<AuthForm onSubmit={this.handleLogin}>
{!error ? null : <ErrorMessage>{error}</ErrorMessage>}

View File

@ -1,10 +1,20 @@
import { API as GithubAPI } from 'netlify-cms-backend-github';
import { APIError } from 'netlify-cms-lib-util';
import { Config as GitHubConfig } from 'netlify-cms-backend-github/src/API';
import { APIError, FetchError } from 'netlify-cms-lib-util';
type Config = GitHubConfig & {
apiRoot: string;
tokenPromise: () => Promise<string>;
commitAuthor: { name: string };
};
export default class API extends GithubAPI {
constructor(config) {
tokenPromise: () => Promise<string>;
commitAuthor: { name: string };
constructor(config: Config) {
super(config);
this.api_root = config.api_root;
this.apiRoot = config.apiRoot;
this.tokenPromise = config.tokenPromise;
this.commitAuthor = config.commitAuthor;
this.repoURL = '';
@ -14,7 +24,7 @@ export default class API extends GithubAPI {
hasWriteAccess() {
return this.getDefaultBranch()
.then(() => true)
.catch(error => {
.catch((error: FetchError) => {
if (error.status === 401) {
if (error.message === 'Bad credentials') {
throw new APIError(
@ -53,16 +63,21 @@ export default class API extends GithubAPI {
});
}
handleRequestError(error, responseStatus) {
handleRequestError(error: FetchError & { msg: string }, responseStatus: number) {
throw new APIError(error.message || error.msg, responseStatus, 'Git Gateway');
}
user() {
return Promise.resolve(this.commitAuthor);
return Promise.resolve({ login: '', ...this.commitAuthor });
}
commit(message, changeTree) {
const commitParams = {
commit(message: string, changeTree: { parentSha?: string; sha: string }) {
const commitParams: {
message: string;
tree: string;
parents: string[];
author?: { name: string; date: string };
} = {
message,
tree: changeTree.sha,
parents: changeTree.parentSha ? [changeTree.parentSha] : [],

View File

@ -1,16 +1,21 @@
import { flow } from 'lodash';
import { API as GitlabAPI } from 'netlify-cms-backend-gitlab';
import { unsentRequest, then } from 'netlify-cms-lib-util';
import { Config as GitHubConfig, CommitAuthor } from 'netlify-cms-backend-gitlab/src/API';
import { unsentRequest, then, ApiRequest } from 'netlify-cms-lib-util';
type Config = GitHubConfig & { tokenPromise: () => Promise<string>; commitAuthor: CommitAuthor };
export default class API extends GitlabAPI {
constructor(config) {
tokenPromise: () => Promise<string>;
constructor(config: Config) {
super(config);
this.tokenPromise = config.tokenPromise;
this.commitAuthor = config.commitAuthor;
this.repoURL = '';
}
authenticateRequest = async req =>
authenticateRequest = async (req: ApiRequest) =>
unsentRequest.withHeaders(
{
Authorization: `Bearer ${await this.tokenPromise()}`,
@ -18,7 +23,7 @@ export default class API extends GitlabAPI {
req,
);
request = async req =>
request = async (req: ApiRequest) =>
flow([this.buildRequest, this.authenticateRequest, then(unsentRequest.performRequest)])(req);
hasWriteAccess = () => Promise.resolve(true);

View File

@ -1,5 +1,4 @@
import React from 'react';
import { Map } from 'immutable';
import { render } from '@testing-library/react';
window.netlifyIdentity = {
@ -10,7 +9,7 @@ window.netlifyIdentity = {
describe('GitGatewayAuthenticationPage', () => {
const props = {
config: Map({ logo_url: 'logo_url' }),
config: { logo_url: 'logo_url' },
t: jest.fn(key => key),
onLogin: jest.fn(),
inProgress: false,

View File

@ -14,7 +14,7 @@ describe('github API', () => {
it('should fetch url with authorization header', async () => {
const api = new API({
api_root: 'https://site.netlify.com/.netlify/git/github',
apiRoot: 'https://site.netlify.com/.netlify/git/github',
tokenPromise: () => Promise.resolve('token'),
});
@ -40,7 +40,7 @@ describe('github API', () => {
it('should throw error on not ok response with message property', async () => {
const api = new API({
api_root: 'https://site.netlify.com/.netlify/git/github',
apiRoot: 'https://site.netlify.com/.netlify/git/github',
tokenPromise: () => Promise.resolve('token'),
});
@ -63,7 +63,7 @@ describe('github API', () => {
it('should throw error on not ok response with msg property', async () => {
const api = new API({
api_root: 'https://site.netlify.com/.netlify/git/github',
apiRoot: 'https://site.netlify.com/.netlify/git/github',
tokenPromise: () => Promise.resolve('token'),
});

View File

@ -2,7 +2,25 @@ import GoTrue from 'gotrue-js';
import jwtDecode from 'jwt-decode';
import { fromPairs, get, pick, intersection, unzip } from 'lodash';
import ini from 'ini';
import { APIError, getBlobSHA, unsentRequest, basename } from 'netlify-cms-lib-util';
import {
APIError,
getBlobSHA,
unsentRequest,
basename,
ApiRequest,
AssetProxy,
PersistOptions,
Entry,
Cursor,
Implementation,
DisplayURL,
User,
Credentials,
entriesByFiles,
Config,
ImplementationFile,
UnpublishedEntryMediaFile,
} from 'netlify-cms-lib-util';
import { GitHubBackend } from 'netlify-cms-backend-github';
import { GitLabBackend } from 'netlify-cms-backend-gitlab';
import { BitbucketBackend, API as BitBucketAPI } from 'netlify-cms-backend-bitbucket';
@ -14,9 +32,17 @@ import {
createPointerFile,
getLargeMediaPatternsFromGitAttributesFile,
getClient,
Client,
PointerFile,
} from './netlify-lfs-client';
const localHosts = {
declare global {
interface Window {
netlifyIdentity?: { gotrue: GoTrue; logout: () => void };
}
}
const localHosts: Record<string, boolean> = {
localhost: true,
'127.0.0.1': true,
'0.0.0.0': true,
@ -27,9 +53,9 @@ const defaults = {
largeMedia: '/.netlify/large-media',
};
function getEndpoint(endpoint, netlifySiteURL) {
function getEndpoint(endpoint: string, netlifySiteURL: string | null) {
if (
localHosts[document.location.host.split(':').shift()] &&
localHosts[document.location.host.split(':').shift() as string] &&
netlifySiteURL &&
endpoint.match(/^\/\.netlify\//)
) {
@ -46,28 +72,57 @@ function getEndpoint(endpoint, netlifySiteURL) {
return endpoint;
}
export default class GitGateway {
constructor(config, options = {}) {
interface NetlifyUser extends Credentials {
jwt: () => Promise<string>;
email: string;
user_metadata: { full_name: string; avatar_url: string };
}
interface GetMediaDisplayURLArgs {
path: string;
original: { id: string; path: string } | string;
largeMedia: PointerFile;
}
export default class GitGateway implements Implementation {
config: Config;
api?: GitHubAPI | GitLabAPI | BitBucketAPI;
branch: string;
squashMerges: boolean;
mediaFolder: string;
transformImages: boolean;
gatewayUrl: string;
netlifyLargeMediaURL: string;
backendType: string | null;
authClient: GoTrue;
backend: GitHubBackend | GitLabBackend | BitbucketBackend | null;
acceptRoles?: string[];
tokenPromise?: () => Promise<string>;
_largeMediaClientPromise?: Promise<Client>;
options: {
proxied: boolean;
API: GitHubAPI | GitLabAPI | BitBucketAPI | null;
initialWorkflowStatus: string;
};
constructor(config: Config, options = {}) {
this.options = {
proxied: true,
API: null,
initialWorkflowStatus: '',
...options,
};
this.config = config;
this.branch = config.getIn(['backend', 'branch'], 'master').trim();
this.squash_merges = config.getIn(['backend', 'squash_merges']);
this.branch = config.backend.branch?.trim() || 'master';
this.squashMerges = config.backend.squash_merges || false;
this.mediaFolder = config.media_folder;
this.transformImages = config.backend.use_large_media_transforms_in_media_library || true;
const netlifySiteURL = localStorage.getItem('netlifySiteURL');
const APIUrl = getEndpoint(
config.getIn(['backend', 'identity_url'], defaults.identity),
netlifySiteURL,
);
this.gatewayUrl = getEndpoint(
config.getIn(['backend', 'gateway_url'], defaults.gateway),
netlifySiteURL,
);
const APIUrl = getEndpoint(config.backend.identity_url || defaults.identity, netlifySiteURL);
this.gatewayUrl = getEndpoint(config.backend.gateway_url || defaults.gateway, netlifySiteURL);
this.netlifyLargeMediaURL = getEndpoint(
config.getIn(['backend', 'large_media_url'], defaults.largeMedia),
config.backend.large_media_url || defaults.largeMedia,
netlifySiteURL,
);
const backendTypeRegex = /\/(github|gitlab|bitbucket)\/?$/;
@ -87,22 +142,27 @@ export default class GitGateway {
this.backend = null;
}
requestFunction = req =>
this.tokenPromise()
.then(token => unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }, req))
requestFunction = (req: ApiRequest) =>
this.tokenPromise!()
.then(
token => unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }, req) as ApiRequest,
)
.then(unsentRequest.performRequest);
authenticate(user) {
authenticate(credentials: Credentials) {
const user = credentials as NetlifyUser;
this.tokenPromise = user.jwt.bind(user);
return this.tokenPromise().then(async token => {
return this.tokenPromise!().then(async token => {
if (!this.backendType) {
const { github_enabled, gitlab_enabled, bitbucket_enabled, roles } = await fetch(
`${this.gatewayUrl}/settings`,
{
headers: { Authorization: `Bearer ${token}` },
},
).then(async res => {
const contentType = res.headers.get('Content-Type');
const {
github_enabled: githubEnabled,
gitlab_enabled: gitlabEnabled,
bitbucket_enabled: bitbucketEnabled,
roles,
} = await fetch(`${this.gatewayUrl}/settings`, {
headers: { Authorization: `Bearer ${token}` },
}).then(async res => {
const contentType = res.headers.get('Content-Type') || '';
if (!contentType.includes('application/json') && !contentType.includes('text/json')) {
throw new APIError(
`Your Git Gateway backend is not returning valid settings. Please make sure it is enabled.`,
@ -124,11 +184,11 @@ export default class GitGateway {
return body;
});
this.acceptRoles = roles;
if (github_enabled) {
if (githubEnabled) {
this.backendType = 'github';
} else if (gitlab_enabled) {
} else if (gitlabEnabled) {
this.backendType = 'gitlab';
} else if (bitbucket_enabled) {
} else if (bitbucketEnabled) {
this.backendType = 'bitbucket';
}
}
@ -142,17 +202,18 @@ export default class GitGateway {
}
const userData = {
name: user.user_metadata.full_name || user.email.split('@').shift(),
name: user.user_metadata.full_name || user.email.split('@').shift()!,
email: user.email,
// eslint-disable-next-line @typescript-eslint/camelcase
avatar_url: user.user_metadata.avatar_url,
metadata: user.user_metadata,
};
const apiConfig = {
api_root: `${this.gatewayUrl}/${this.backendType}`,
apiRoot: `${this.gatewayUrl}/${this.backendType}`,
branch: this.branch,
tokenPromise: this.tokenPromise,
tokenPromise: this.tokenPromise!,
commitAuthor: pick(userData, ['name', 'email']),
squash_merges: this.squash_merges,
squashMerges: this.squashMerges,
initialWorkflowStatus: this.options.initialWorkflowStatus,
};
@ -171,20 +232,21 @@ export default class GitGateway {
this.backend = new BitbucketBackend(this.config, { ...this.options, API: this.api });
}
if (!(await this.api.hasWriteAccess())) {
if (!(await this.api!.hasWriteAccess())) {
throw new Error("You don't have sufficient permissions to access Netlify CMS");
}
return { name: userData.name, login: userData.email };
return { name: userData.name, login: userData.email } as User;
});
}
restoreUser() {
const user = this.authClient && this.authClient.currentUser();
if (!user) return Promise.reject();
return this.authenticate(user);
return this.authenticate(user as Credentials);
}
authComponent() {
return AuthenticationPage;
}
logout() {
if (window.netlifyIdentity) {
return window.netlifyIdentity.logout();
@ -193,44 +255,43 @@ export default class GitGateway {
return user && user.logout();
}
getToken() {
return this.tokenPromise();
return this.tokenPromise!();
}
entriesByFolder(collection, extension) {
return this.backend.entriesByFolder(collection, extension);
entriesByFolder(folder: string, extension: string, depth: number) {
return this.backend!.entriesByFolder(folder, extension, depth);
}
entriesByFiles(collection) {
return this.backend.entriesByFiles(collection);
entriesByFiles(files: ImplementationFile[]) {
return this.backend!.entriesByFiles(files);
}
fetchFiles(files) {
return this.backend.fetchFiles(files);
}
getEntry(collection, slug, path) {
return this.backend.getEntry(collection, slug, path);
getEntry(path: string) {
return this.backend!.getEntry(path);
}
async loadEntryMediaFiles(files) {
async loadEntryMediaFiles(branch: string, files: UnpublishedEntryMediaFile[]) {
const client = await this.getLargeMediaClient();
const backend = this.backend as GitLabBackend | GitHubBackend;
if (!client.enabled) {
return this.backend.loadEntryMediaFiles(files);
return backend!.loadEntryMediaFiles(branch, files);
}
const mediaFiles = await Promise.all(
files.map(async file => {
if (client.matchPath(file.path)) {
const { sha: id, path } = file;
const { id, path } = file;
const largeMediaDisplayURLs = await this.getLargeMediaDisplayURLs([{ ...file, id }]);
const url = await client.getDownloadURL(largeMediaDisplayURLs[id]);
return {
...file,
id,
name: basename(path),
path,
url,
displayURL: url,
file: new File([], name),
size: 0,
};
} else {
return this.backend.loadMediaFile(file);
return backend!.loadMediaFile(branch, file);
}
}),
);
@ -238,8 +299,8 @@ export default class GitGateway {
return mediaFiles;
}
getMedia(mediaFolder = this.config.get('media_folder')) {
return Promise.all([this.backend.getMedia(mediaFolder), this.getLargeMediaClient()]).then(
getMedia(mediaFolder = this.mediaFolder) {
return Promise.all([this.backend!.getMedia(mediaFolder), this.getLargeMediaClient()]).then(
async ([mediaFiles, largeMediaClient]) => {
if (!largeMediaClient.enabled) {
return mediaFiles.map(({ displayURL, ...rest }) => ({
@ -277,23 +338,21 @@ export default class GitGateway {
return this._largeMediaClientPromise;
}
_getLargeMediaClient() {
const netlifyLargeMediaEnabledPromise = this.api
.readFile('.lfsconfig')
.then(ini.decode)
const netlifyLargeMediaEnabledPromise = this.api!.readFile('.lfsconfig')
.then(config => ini.decode<{ lfs: { url: string } }>(config as string))
.then(({ lfs: { url } }) => new URL(url))
.then(lfsURL => ({ enabled: lfsURL.hostname.endsWith('netlify.com') }))
.catch(err => ({ enabled: false, err }));
.catch((err: Error) => ({ enabled: false, err }));
const lfsPatternsPromise = this.api
.readFile('.gitattributes')
.then(getLargeMediaPatternsFromGitAttributesFile)
.then(patterns => ({ patterns }))
.catch(err => {
const lfsPatternsPromise = this.api!.readFile('.gitattributes')
.then(attributes => getLargeMediaPatternsFromGitAttributesFile(attributes as string))
.then((patterns: string[]) => ({ err: null, patterns }))
.catch((err: Error) => {
if (err.message.includes('404')) {
console.log('This 404 was expected and handled appropriately.');
return [];
return { err: null, patterns: [] as string[] };
} else {
return { err };
return { err, patterns: [] as string[] };
}
});
@ -312,29 +371,29 @@ export default class GitGateway {
rootURL: this.netlifyLargeMediaURL,
makeAuthorizedRequest: this.requestFunction,
patterns,
transformImages: this.config.getIn(
['backend', 'use_large_media_transforms_in_media_library'],
true,
)
? { nf_resize: 'fit', w: 560, h: 320 }
transformImages: this.transformImages
? // eslint-disable-next-line @typescript-eslint/camelcase
{ nf_resize: 'fit', w: 560, h: 320 }
: false,
});
},
);
}
async getLargeMediaDisplayURLs(mediaFiles) {
async getLargeMediaDisplayURLs(mediaFiles: { path: string; id: string | null }[]) {
const client = await this.getLargeMediaClient();
const largeMediaItems = mediaFiles
.filter(({ path }) => client.matchPath(path))
.map(({ id, path }) => ({ path, sha: id }));
return this.backend
.fetchFiles(largeMediaItems)
const filesPromise = entriesByFiles(
mediaFiles,
this.api!.readFile.bind(this.api!),
'Git-Gateway',
);
return filesPromise
.then(items =>
items.map(({ file: { sha }, data }) => {
items.map(({ file: { id }, data }) => {
const parsedPointerFile = parsePointerFile(data);
return [
{
pointerId: sha,
pointerId: id,
resourceId: parsedPointerFile.sha,
},
parsedPointerFile,
@ -343,7 +402,10 @@ export default class GitGateway {
)
.then(unzip)
.then(([idMaps, files]) =>
Promise.all([idMaps, client.getResourceDownloadURLArgs(files).then(fromPairs)]),
Promise.all([
idMaps as { pointerId: string; resourceId: string }[],
client.getResourceDownloadURLArgs(files as PointerFile[]).then(r => fromPairs(r)),
]),
)
.then(([idMaps, resourceMap]) =>
idMaps.map(({ pointerId, resourceId }) => [pointerId, resourceMap[resourceId]]),
@ -351,8 +413,12 @@ export default class GitGateway {
.then(fromPairs);
}
getMediaDisplayURL(displayURL) {
const { path, original, largeMedia: largeMediaDisplayURL } = displayURL;
getMediaDisplayURL(displayURL: DisplayURL) {
const {
path,
original,
largeMedia: largeMediaDisplayURL,
} = (displayURL as unknown) as GetMediaDisplayURLArgs;
return this.getLargeMediaClient().then(client => {
if (client.enabled && client.matchPath(path)) {
return client.getDownloadURL(largeMediaDisplayURL);
@ -360,33 +426,36 @@ export default class GitGateway {
if (typeof original === 'string') {
return original;
}
if (this.backend.getMediaDisplayURL) {
return this.backend.getMediaDisplayURL(original);
if (this.backend!.getMediaDisplayURL) {
return this.backend!.getMediaDisplayURL(original);
}
const err = new Error(
`getMediaDisplayURL is not implemented by the ${this.backendType} backend, but the backend returned a displayURL which was not a string!`,
);
) as Error & {
displayURL: DisplayURL;
};
err.displayURL = displayURL;
return Promise.reject(err);
});
}
async getMediaFile(path) {
async getMediaFile(path: string) {
const client = await this.getLargeMediaClient();
if (client.enabled && client.matchPath(path)) {
const largeMediaDisplayURLs = await this.getLargeMediaDisplayURLs([{ path }]);
const largeMediaDisplayURLs = await this.getLargeMediaDisplayURLs([{ path, id: null }]);
const url = await client.getDownloadURL(Object.values(largeMediaDisplayURLs)[0]);
return {
id: url,
name: basename(path),
path,
url,
displayURL: url,
};
}
return this.backend.getMediaFile(path);
return this.backend!.getMediaFile(path);
}
async getPointerFileForMediaFileObj(fileObj) {
async getPointerFileForMediaFileObj(fileObj: File) {
const client = await this.getLargeMediaClient();
const { name, size } = fileObj;
const sha = await getBlobSHA(fileObj);
@ -403,10 +472,10 @@ export default class GitGateway {
};
}
async persistEntry(entry, mediaFiles, options) {
async persistEntry(entry: Entry, mediaFiles: AssetProxy[], options: PersistOptions) {
const client = await this.getLargeMediaClient();
if (!client.enabled) {
return this.backend.persistEntry(entry, mediaFiles, options);
return this.backend!.persistEntry(entry, mediaFiles, options);
}
const largeMediaFilteredMediaFiles = await Promise.all(
@ -417,7 +486,7 @@ export default class GitGateway {
return mediaFile;
}
const pointerFileDetails = await this.getPointerFileForMediaFileObj(fileObj);
const pointerFileDetails = await this.getPointerFileForMediaFileObj(fileObj as File);
return {
...mediaFile,
fileObj: pointerFileDetails.file,
@ -428,62 +497,55 @@ export default class GitGateway {
}),
);
return this.backend.persistEntry(entry, largeMediaFilteredMediaFiles, options);
return this.backend!.persistEntry(entry, largeMediaFilteredMediaFiles, options);
}
async persistMedia(mediaFile, options) {
const { fileObj, path, value } = mediaFile;
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
const { fileObj, path } = mediaFile;
const displayURL = URL.createObjectURL(fileObj);
const client = await this.getLargeMediaClient();
const fixedPath = path.startsWith('/') ? path.slice(1) : path;
if (!client.enabled || !client.matchPath(fixedPath)) {
return this.backend.persistMedia(mediaFile, options);
return this.backend!.persistMedia(mediaFile, options);
}
const pointerFileDetails = await this.getPointerFileForMediaFileObj(fileObj);
const pointerFileDetails = await this.getPointerFileForMediaFileObj(fileObj as File);
const persistMediaArgument = {
fileObj: pointerFileDetails.file,
size: pointerFileDetails.blob.size,
path,
sha: pointerFileDetails.sha,
raw: pointerFileDetails.raw,
value,
};
return {
...(await this.backend.persistMedia(persistMediaArgument, options)),
...(await this.backend!.persistMedia(persistMediaArgument, options)),
displayURL,
};
}
deleteFile(path, commitMessage, options) {
return this.backend.deleteFile(path, commitMessage, options);
deleteFile(path: string, commitMessage: string) {
return this.backend!.deleteFile(path, commitMessage);
}
getDeployPreview(collection, slug) {
if (this.backend.getDeployPreview) {
return this.backend.getDeployPreview(collection, slug);
}
async getDeployPreview(collection: string, slug: string) {
return this.backend!.getDeployPreview(collection, slug);
}
unpublishedEntries() {
return this.backend.unpublishedEntries();
return this.backend!.unpublishedEntries();
}
unpublishedEntry(collection, slug) {
if (!this.backend.unpublishedEntry) {
return Promise.resolve(false);
}
return this.backend.unpublishedEntry(collection, slug, {
loadEntryMediaFiles: files => this.loadEntryMediaFiles(files),
unpublishedEntry(collection: string, slug: string) {
return this.backend!.unpublishedEntry(collection, slug, {
loadEntryMediaFiles: (branch, files) => this.loadEntryMediaFiles(branch, files),
});
}
updateUnpublishedEntryStatus(collection, slug, newStatus) {
return this.backend.updateUnpublishedEntryStatus(collection, slug, newStatus);
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
return this.backend!.updateUnpublishedEntryStatus(collection, slug, newStatus);
}
deleteUnpublishedEntry(collection, slug) {
return this.backend.deleteUnpublishedEntry(collection, slug);
deleteUnpublishedEntry(collection: string, slug: string) {
return this.backend!.deleteUnpublishedEntry(collection, slug);
}
publishUnpublishedEntry(collection, slug) {
return this.backend.publishUnpublishedEntry(collection, slug);
publishUnpublishedEntry(collection: string, slug: string) {
return this.backend!.publishUnpublishedEntry(collection, slug);
}
traverseCursor(cursor, action) {
return this.backend.traverseCursor(cursor, action);
traverseCursor(cursor: Cursor, action: string) {
return (this.backend as GitLabBackend | BitbucketBackend).traverseCursor!(cursor, action);
}
}

View File

@ -1,183 +0,0 @@
import { filter, flow, fromPairs, map } from 'lodash/fp';
import minimatch from 'minimatch';
//
// Pointer file parsing
const splitIntoLines = str => str.split('\n');
const splitIntoWords = str => str.split(/\s+/g);
const isNonEmptyString = str => str !== '';
const withoutEmptyLines = flow([map(str => str.trim()), filter(isNonEmptyString)]);
export const parsePointerFile = flow([
splitIntoLines,
withoutEmptyLines,
map(splitIntoWords),
fromPairs,
({ size, oid, ...rest }) => ({
size: parseInt(size),
sha: oid.split(':')[1],
...rest,
}),
]);
export const createPointerFile = ({ size, sha }) => `\
version https://git-lfs.github.com/spec/v1
oid sha256:${sha}
size ${size}
`;
//
// .gitattributes file parsing
const removeGitAttributesCommentsFromLine = line => line.split('#')[0];
const parseGitPatternAttribute = attributeString => {
// There are three kinds of attribute settings:
// - a key=val pair sets an attribute to a specific value
// - a key without a value and a leading hyphen sets an attribute to false
// - a key without a value and no leading hyphen sets an attribute
// to true
if (attributeString.includes('=')) {
return attributeString.split('=');
}
if (attributeString.startsWith('-')) {
return [attributeString.slice(1), false];
}
return [attributeString, true];
};
const parseGitPatternAttributes = flow([map(parseGitPatternAttribute), fromPairs]);
const parseGitAttributesPatternLine = flow([
splitIntoWords,
([pattern, ...attributes]) => [pattern, parseGitPatternAttributes(attributes)],
]);
const parseGitAttributesFileToPatternAttributePairs = flow([
splitIntoLines,
map(removeGitAttributesCommentsFromLine),
withoutEmptyLines,
map(parseGitAttributesPatternLine),
]);
export const getLargeMediaPatternsFromGitAttributesFile = flow([
parseGitAttributesFileToPatternAttributePairs,
filter(
// eslint-disable-next-line no-unused-vars
([pattern, attributes]) =>
attributes.filter === 'lfs' && attributes.diff === 'lfs' && attributes.merge === 'lfs',
),
map(([pattern]) => pattern),
]);
export const matchPath = ({ patterns }, path) =>
patterns.some(pattern => minimatch(path, pattern, { matchBase: true }));
//
// API interactions
const defaultContentHeaders = {
Accept: 'application/vnd.git-lfs+json',
['Content-Type']: 'application/vnd.git-lfs+json',
};
const resourceExists = async ({ rootURL, makeAuthorizedRequest }, { sha, size }) => {
const response = await makeAuthorizedRequest({
url: `${rootURL}/verify`,
method: 'POST',
headers: defaultContentHeaders,
body: JSON.stringify({ oid: sha, size }),
});
if (response.ok) {
return true;
}
if (response.status === 404) {
return false;
}
// TODO: what kind of error to throw here? APIError doesn't seem
// to fit
};
const getDownloadURL = ({ rootURL, transformImages: t, makeAuthorizedRequest }, { sha }) =>
makeAuthorizedRequest(
`${rootURL}/origin/${sha}${
t && Object.keys(t).length > 0 ? `?nf_resize=${t.nf_resize}&w=${t.w}&h=${t.h}` : ''
}`,
)
.then(res => (res.ok ? res : Promise.reject(res)))
.then(res => res.blob())
.then(blob => URL.createObjectURL(blob))
.catch(err => console.error(err) || Promise.resolve(''));
const getResourceDownloadURLArgs = (clientConfig, objects) => {
return Promise.resolve(objects.map(({ sha }) => [sha, { sha }]));
};
const getResourceDownloadURLs = (clientConfig, objects) =>
getResourceDownloadURLArgs(clientConfig, objects)
.then(map(downloadURLArg => getDownloadURL(downloadURLArg)))
.then(Promise.all.bind(Promise));
const uploadOperation = objects => ({
operation: 'upload',
transfers: ['basic'],
objects: objects.map(({ sha, ...rest }) => ({ ...rest, oid: sha })),
});
const getResourceUploadURLs = async ({ rootURL, makeAuthorizedRequest }, objects) => {
const response = await makeAuthorizedRequest({
url: `${rootURL}/objects/batch`,
method: 'POST',
headers: defaultContentHeaders,
body: JSON.stringify(uploadOperation(objects)),
});
return (await response.json()).objects.map(object => {
if (object.error) {
throw new Error(object.error.message);
}
return object.actions.upload.href;
});
};
const uploadBlob = (clientConfig, uploadURL, blob) =>
fetch(uploadURL, {
method: 'PUT',
body: blob,
});
const uploadResource = async (clientConfig, { sha, size }, resource) => {
const existingFile = await resourceExists(clientConfig, { sha, size });
if (existingFile) {
return sha;
}
const [uploadURL] = await getResourceUploadURLs(clientConfig, [{ sha, size }]);
await uploadBlob(clientConfig, uploadURL, resource);
return sha;
};
//
// Create Large Media client
const configureFn = (config, fn) => (...args) => fn(config, ...args);
const clientFns = {
resourceExists,
getResourceUploadURLs,
getResourceDownloadURLs,
getResourceDownloadURLArgs,
getDownloadURL,
uploadResource,
matchPath,
};
export const getClient = clientConfig => {
return flow([
Object.keys,
map(key => [key, configureFn(clientConfig, clientFns[key])]),
fromPairs,
configuredFns => ({
...configuredFns,
patterns: clientConfig.patterns,
enabled: clientConfig.enabled,
}),
])(clientFns);
};

View File

@ -0,0 +1,234 @@
import { filter, flow, fromPairs, map } from 'lodash/fp';
import minimatch from 'minimatch';
import { ApiRequest } from 'netlify-cms-lib-util';
//
// Pointer file parsing
const splitIntoLines = (str: string) => str.split('\n');
const splitIntoWords = (str: string) => str.split(/\s+/g);
const isNonEmptyString = (str: string) => str !== '';
const withoutEmptyLines = flow([map((str: string) => str.trim()), filter(isNonEmptyString)]);
export const parsePointerFile: (data: string) => PointerFile = flow([
splitIntoLines,
withoutEmptyLines,
map(splitIntoWords),
fromPairs,
({ size, oid, ...rest }) => ({
size: parseInt(size),
sha: oid.split(':')[1],
...rest,
}),
]);
export type PointerFile = {
size: number;
sha: string;
};
type MakeAuthorizedRequest = (req: ApiRequest) => Promise<Response>;
type ImageTransformations = { nf_resize: string; w: number; h: number };
type ClientConfig = {
rootURL: string;
makeAuthorizedRequest: MakeAuthorizedRequest;
patterns: string[];
enabled: boolean;
transformImages: ImageTransformations | boolean;
};
export const createPointerFile = ({ size, sha }: PointerFile) => `\
version https://git-lfs.github.com/spec/v1
oid sha256:${sha}
size ${size}
`;
//
// .gitattributes file parsing
const removeGitAttributesCommentsFromLine = (line: string) => line.split('#')[0];
const parseGitPatternAttribute = (attributeString: string) => {
// There are three kinds of attribute settings:
// - a key=val pair sets an attribute to a specific value
// - a key without a value and a leading hyphen sets an attribute to false
// - a key without a value and no leading hyphen sets an attribute
// to true
if (attributeString.includes('=')) {
return attributeString.split('=');
}
if (attributeString.startsWith('-')) {
return [attributeString.slice(1), false];
}
return [attributeString, true];
};
const parseGitPatternAttributes = flow([map(parseGitPatternAttribute), fromPairs]);
const parseGitAttributesPatternLine = flow([
splitIntoWords,
([pattern, ...attributes]) => [pattern, parseGitPatternAttributes(attributes)],
]);
const parseGitAttributesFileToPatternAttributePairs = flow([
splitIntoLines,
map(removeGitAttributesCommentsFromLine),
withoutEmptyLines,
map(parseGitAttributesPatternLine),
]);
export const getLargeMediaPatternsFromGitAttributesFile = flow([
parseGitAttributesFileToPatternAttributePairs,
filter(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
([_pattern, attributes]) =>
attributes.filter === 'lfs' && attributes.diff === 'lfs' && attributes.merge === 'lfs',
),
map(([pattern]) => pattern),
]);
export const matchPath = ({ patterns }: ClientConfig, path: string) =>
patterns.some(pattern => minimatch(path, pattern, { matchBase: true }));
//
// API interactions
const defaultContentHeaders = {
Accept: 'application/vnd.git-lfs+json',
['Content-Type']: 'application/vnd.git-lfs+json',
};
const resourceExists = async (
{ rootURL, makeAuthorizedRequest }: ClientConfig,
{ sha, size }: PointerFile,
) => {
const response = await makeAuthorizedRequest({
url: `${rootURL}/verify`,
method: 'POST',
headers: defaultContentHeaders,
body: JSON.stringify({ oid: sha, size }),
});
if (response.ok) {
return true;
}
if (response.status === 404) {
return false;
}
// TODO: what kind of error to throw here? APIError doesn't seem
// to fit
};
const getTransofrmationsParams = (t: ImageTransformations) =>
`?nf_resize=${t.nf_resize}&w=${t.w}&h=${t.h}`;
const getDownloadURL = (
{ rootURL, transformImages: t, makeAuthorizedRequest }: ClientConfig,
{ sha }: PointerFile,
) =>
makeAuthorizedRequest(
`${rootURL}/origin/${sha}${
t && Object.keys(t).length > 0 ? getTransofrmationsParams(t as ImageTransformations) : ''
}`,
)
.then(res => (res.ok ? res : Promise.reject(res)))
.then(res => res.blob())
.then(blob => URL.createObjectURL(blob))
.catch((err: Error) => {
console.error(err);
return Promise.resolve('');
});
const getResourceDownloadURLArgs = (_clientConfig: ClientConfig, objects: PointerFile[]) => {
const result = objects.map(({ sha }) => [sha, { sha }]) as [string, { sha: string }][];
return Promise.resolve(result);
};
const uploadOperation = (objects: PointerFile[]) => ({
operation: 'upload',
transfers: ['basic'],
objects: objects.map(({ sha, ...rest }) => ({ ...rest, oid: sha })),
});
const getResourceUploadURLs = async (
{
rootURL,
makeAuthorizedRequest,
}: { rootURL: string; makeAuthorizedRequest: MakeAuthorizedRequest },
objects: PointerFile[],
) => {
const response = await makeAuthorizedRequest({
url: `${rootURL}/objects/batch`,
method: 'POST',
headers: defaultContentHeaders,
body: JSON.stringify(uploadOperation(objects)),
});
return (await response.json()).objects.map(
(object: { error?: { message: string }; actions: { upload: { href: string } } }) => {
if (object.error) {
throw new Error(object.error.message);
}
return object.actions.upload.href;
},
);
};
const uploadBlob = (uploadURL: string, blob: Blob) =>
fetch(uploadURL, {
method: 'PUT',
body: blob,
});
const uploadResource = async (
clientConfig: ClientConfig,
{ sha, size }: PointerFile,
resource: Blob,
) => {
const existingFile = await resourceExists(clientConfig, { sha, size });
if (existingFile) {
return sha;
}
const [uploadURL] = await getResourceUploadURLs(clientConfig, [{ sha, size }]);
await uploadBlob(uploadURL, resource);
return sha;
};
//
// Create Large Media client
const configureFn = (config: ClientConfig, fn: Function) => (...args: unknown[]) =>
fn(config, ...args);
const clientFns: Record<string, Function> = {
resourceExists,
getResourceUploadURLs,
getResourceDownloadURLArgs,
getDownloadURL,
uploadResource,
matchPath,
};
export type Client = {
resourceExists: (pointer: PointerFile) => Promise<boolean | undefined>;
getResourceUploadURLs: (objects: PointerFile[]) => Promise<string>;
getResourceDownloadURLArgs: (objects: PointerFile[]) => Promise<[string, { sha: string }][]>;
getDownloadURL: (pointer: PointerFile) => Promise<string>;
uploadResource: (pointer: PointerFile, blob: Blob) => Promise<string>;
matchPath: (path: string) => boolean;
patterns: string[];
enabled: boolean;
};
export const getClient = (clientConfig: ClientConfig) => {
return flow([
Object.keys,
map((key: string) => [key, configureFn(clientConfig, clientFns[key])]),
fromPairs,
configuredFns => ({
...configuredFns,
patterns: clientConfig.patterns,
enabled: clientConfig.enabled,
}),
])(clientFns);
};

View File

@ -0,0 +1,4 @@
declare module 'ini' {
const ini: { decode: <T>(ini: string) => T };
export default ini;
}