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:
committed by
Shawn Erquhart
parent
4ff5bc2ee0
commit
6f221ab3c1
@ -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>}
|
||||
|
@ -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] : [],
|
@ -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);
|
@ -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,
|
||||
|
@ -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'),
|
||||
});
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
};
|
@ -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);
|
||||
};
|
4
packages/netlify-cms-backend-git-gateway/src/types/ini.d.ts
vendored
Normal file
4
packages/netlify-cms-backend-git-gateway/src/types/ini.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module 'ini' {
|
||||
const ini: { decode: <T>(ini: string) => T };
|
||||
export default ini;
|
||||
}
|
Reference in New Issue
Block a user