planning
All checks were successful
Publish To Prod / deploy_and_publish (push) Successful in 35s

This commit is contained in:
2024-10-14 09:15:30 +02:00
parent bcba00a730
commit 6e64e138e2
21059 changed files with 2317811 additions and 1 deletions

View File

@@ -0,0 +1,227 @@
import PropTypes from 'prop-types';
import React from 'react';
import styled from '@emotion/styled';
import { partial } from 'lodash';
import {
AuthenticationPage,
buttons,
shadows,
colors,
colorsRaw,
lengths,
zIndex,
} from 'decap-cms-ui-default';
const LoginButton = styled.button`
${buttons.button};
${shadows.dropDeep};
${buttons.default};
${buttons.gray};
padding: 0 30px;
display: block;
margin-top: 20px;
margin-left: auto;
`;
const AuthForm = styled.form`
width: 350px;
margin-top: -80px;
`;
const AuthInput = styled.input`
background-color: ${colorsRaw.white};
border-radius: ${lengths.borderRadius};
font-size: 14px;
padding: 10px;
margin-bottom: 15px;
margin-top: 6px;
width: 100%;
position: relative;
z-index: ${zIndex.zIndex1};
&:focus {
outline: none;
box-shadow: inset 0 0 0 2px ${colors.active};
}
`;
const ErrorMessage = styled.p`
color: ${colors.errorText};
`;
let component = null;
if (window.netlifyIdentity) {
window.netlifyIdentity.on('login', user => {
component && component.handleIdentityLogin(user);
});
window.netlifyIdentity.on('logout', () => {
component && component.handleIdentityLogout();
});
window.netlifyIdentity.on('error', err => {
component && component.handleIdentityError(err);
});
}
export default class GitGatewayAuthenticationPage extends React.Component {
static authClient;
constructor(props) {
super(props);
component = this;
}
componentDidMount() {
if (!this.loggedIn && window.netlifyIdentity && window.netlifyIdentity.currentUser()) {
this.props.onLogin(window.netlifyIdentity.currentUser());
window.netlifyIdentity.close();
}
}
componentWillUnmount() {
component = null;
}
handleIdentityLogin = user => {
this.props.onLogin(user);
window.netlifyIdentity.close();
};
handleIdentityLogout = () => {
window.netlifyIdentity.open();
};
handleIdentityError = err => {
if (err?.message?.match(/^Failed to load settings from.+\.netlify\/identity$/)) {
window.netlifyIdentity.close();
this.setState({
errors: { identity: this.props.t('auth.errors.identitySettings') },
});
}
};
handleIdentity = () => {
const user = window.netlifyIdentity.currentUser();
if (user) {
this.props.onLogin(user);
} else {
window.netlifyIdentity.open();
}
};
static propTypes = {
onLogin: PropTypes.func.isRequired,
inProgress: PropTypes.bool.isRequired,
error: PropTypes.node,
config: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
};
state = { email: '', password: '', errors: {} };
handleChange = (name, e) => {
this.setState({ ...this.state, [name]: e.target.value });
};
handleLogin = async e => {
e.preventDefault();
const { email, password } = this.state;
const { t } = this.props;
const errors = {};
if (!email) {
errors.email = t('auth.errors.email');
}
if (!password) {
errors.password = t('auth.errors.password');
}
if (Object.keys(errors).length > 0) {
this.setState({ errors });
return;
}
try {
const client = await GitGatewayAuthenticationPage.authClient();
const user = await client.login(this.state.email, this.state.password, true);
this.props.onLogin(user);
} catch (error) {
this.setState({
errors: { server: error.description || error.msg || error },
loggingIn: false,
});
}
};
render() {
const { errors } = this.state;
const { error, inProgress, config, t } = this.props;
if (window.netlifyIdentity) {
if (errors.identity) {
return (
<AuthenticationPage
logoUrl={config.logo_url}
siteUrl={config.site_url}
onLogin={this.handleIdentity}
renderPageContent={() => (
<a
href="https://docs.netlify.com/visitor-access/git-gateway/#setup-and-settings"
target="_blank"
rel="noopener noreferrer"
>
{errors.identity}
</a>
)}
t={t}
/>
);
} else {
return (
<AuthenticationPage
logoUrl={config.logo_url}
siteUrl={config.site_url}
onLogin={this.handleIdentity}
renderButtonContent={() => t('auth.loginWithNetlifyIdentity')}
t={t}
/>
);
}
}
return (
<AuthenticationPage
logoUrl={config.logo_url}
siteUrl={config.site_url}
renderPageContent={() => (
<AuthForm onSubmit={this.handleLogin}>
{!error ? null : <ErrorMessage>{error}</ErrorMessage>}
{!errors.server ? null : <ErrorMessage>{String(errors.server)}</ErrorMessage>}
<ErrorMessage>{errors.email || null}</ErrorMessage>
<AuthInput
type="text"
name="email"
placeholder="Email"
value={this.state.email}
onChange={partial(this.handleChange, 'email')}
/>
<ErrorMessage>{errors.password || null}</ErrorMessage>
<AuthInput
type="password"
name="password"
placeholder="Password"
value={this.state.password}
onChange={partial(this.handleChange, 'password')}
/>
<LoginButton disabled={inProgress}>
{inProgress ? t('auth.loggingIn') : t('auth.login')}
</LoginButton>
</AuthForm>
)}
t={t}
/>
);
}
}

View File

@@ -0,0 +1,133 @@
import { API as GithubAPI } from 'decap-cms-backend-github';
import { APIError } from 'decap-cms-lib-util';
import type { Config as GitHubConfig, Diff } from 'decap-cms-backend-github/src/API';
import type { FetchError } from 'decap-cms-lib-util';
import type { Octokit } from '@octokit/rest';
type Config = Omit<GitHubConfig, 'getUser'> & {
apiRoot: string;
tokenPromise: () => Promise<string>;
commitAuthor: { name: string };
isLargeMedia: (filename: string) => Promise<boolean>;
};
export default class API extends GithubAPI {
tokenPromise: () => Promise<string>;
commitAuthor: { name: string };
isLargeMedia: (filename: string) => Promise<boolean>;
constructor(config: Config) {
super({
getUser: () => Promise.reject('Never used'),
...config,
});
this.apiRoot = config.apiRoot;
this.tokenPromise = config.tokenPromise;
this.commitAuthor = config.commitAuthor;
this.isLargeMedia = config.isLargeMedia;
this.repoURL = '';
this.originRepoURL = '';
}
hasWriteAccess() {
return this.getDefaultBranch()
.then(() => true)
.catch((error: FetchError) => {
if (error.status === 401) {
if (error.message === 'Bad credentials') {
throw new APIError(
'Git Gateway Error: Please ask your site administrator to reissue the Git Gateway token.',
error.status,
'Git Gateway',
);
} else {
return false;
}
} else if (
error.status === 404 &&
(error.message === undefined || error.message === 'Unable to locate site configuration')
) {
throw new APIError(
`Git Gateway Error: Please make sure Git Gateway is enabled on your site.`,
error.status,
'Git Gateway',
);
} else {
console.error('Problem fetching repo data from Git Gateway');
throw error;
}
});
}
requestHeaders(headers = {}) {
return this.tokenPromise().then(jwtToken => {
const baseHeader = {
Authorization: `Bearer ${jwtToken}`,
'Content-Type': 'application/json; charset=utf-8',
...headers,
};
return baseHeader;
});
}
handleRequestError(error: FetchError & { msg: string }, responseStatus: number) {
throw new APIError(error.message || error.msg, responseStatus, 'Git Gateway');
}
user() {
return Promise.resolve({ login: '', ...this.commitAuthor });
}
async getHeadReference(head: string) {
if (!this.repoOwner) {
// get the repo owner from the branch url
// this is required for returning the full head reference, e.g. owner:head
// when filtering pull requests based on the head
const branch = await this.getDefaultBranch();
const self = branch._links.self;
const regex = new RegExp('https?://.+?/repos/(.+?)/');
const owner = self.match(regex);
this.repoOwner = owner ? owner[1] : '';
}
return super.getHeadReference(head);
}
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] : [],
};
if (this.commitAuthor) {
commitParams.author = {
...this.commitAuthor,
date: new Date().toISOString(),
};
}
return this.request('/git/commits', {
method: 'POST',
body: JSON.stringify(commitParams),
});
}
nextUrlProcessor() {
return (url: string) => url.replace(/^(?:[a-z]+:\/\/.+?\/.+?\/.+?\/)/, `${this.apiRoot}/`);
}
async diffFromFile(file: Octokit.ReposCompareCommitsResponseFilesItem): Promise<Diff> {
const diff = await super.diffFromFile(file);
return {
...diff,
binary: diff.binary || (await this.isLargeMedia(file.filename)),
};
}
}

View File

@@ -0,0 +1,30 @@
import { API as GitlabAPI } from 'decap-cms-backend-gitlab';
import { unsentRequest } from 'decap-cms-lib-util';
import type { Config as GitLabConfig, CommitAuthor } from 'decap-cms-backend-gitlab/src/API';
import type { ApiRequest } from 'decap-cms-lib-util';
type Config = GitLabConfig & { tokenPromise: () => Promise<string>; commitAuthor: CommitAuthor };
export default class API extends GitlabAPI {
tokenPromise: () => Promise<string>;
constructor(config: Config) {
super(config);
this.tokenPromise = config.tokenPromise;
this.commitAuthor = config.commitAuthor;
this.repoURL = '';
}
withAuthorizationHeaders = async (req: ApiRequest) => {
const token = await this.tokenPromise();
return unsentRequest.withHeaders(
{
Authorization: `Bearer ${token}`,
},
req,
);
};
hasWriteAccess = () => Promise.resolve(true);
}

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { render } from '@testing-library/react';
import GitGatewayAuthenticationPage from '../AuthenticationPage';
window.netlifyIdentity = {
currentUser: jest.fn(),
on: jest.fn(),
close: jest.fn(),
};
describe('GitGatewayAuthenticationPage', () => {
const props = {
config: { logo_url: 'logo_url' },
t: jest.fn(key => key),
onLogin: jest.fn(),
inProgress: false,
};
beforeEach(() => {
jest.clearAllMocks();
jest.resetModules();
});
it('should render with identity error', () => {
// obtain mock calls
require('../AuthenticationPage');
function TestComponent() {
const { asFragment } = render(<GitGatewayAuthenticationPage {...props} />);
const errorCallback = window.netlifyIdentity.on.mock.calls.find(
call => call[0] === 'error',
)[1];
errorCallback(
new Error('Failed to load settings from https://site.netlify.com/.netlify/identity'),
);
expect(asFragment()).toMatchSnapshot();
}
TestComponent();
});
test('should render with no identity error', () => {
function TestComponent() {
const { asFragment } = render(<GitGatewayAuthenticationPage {...props} />);
expect(asFragment()).toMatchSnapshot();
}
TestComponent();
});
});

View File

@@ -0,0 +1,97 @@
import API from '../GitHubAPI';
describe('github API', () => {
describe('request', () => {
beforeEach(() => {
const fetch = jest.fn();
global.fetch = fetch;
});
afterEach(() => {
jest.resetAllMocks();
});
it('should fetch url with authorization header', async () => {
const api = new API({
apiRoot: 'https://site.netlify.com/.netlify/git/github',
tokenPromise: () => Promise.resolve('token'),
});
fetch.mockResolvedValue({
text: jest.fn().mockResolvedValue('some response'),
ok: true,
status: 200,
headers: { get: () => '' },
});
const result = await api.request('/some-path');
expect(result).toEqual('some response');
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith('https://site.netlify.com/.netlify/git/github/some-path', {
cache: 'no-cache',
headers: {
Authorization: 'Bearer token',
'Content-Type': 'application/json; charset=utf-8',
},
signal: expect.any(AbortSignal),
});
});
it('should throw error on not ok response with message property', async () => {
const api = new API({
apiRoot: 'https://site.netlify.com/.netlify/git/github',
tokenPromise: () => Promise.resolve('token'),
});
fetch.mockResolvedValue({
text: jest.fn().mockResolvedValue({ message: 'some error' }),
ok: false,
status: 404,
headers: { get: () => '' },
});
await expect(api.request('some-path')).rejects.toThrow(
expect.objectContaining({
message: 'some error',
name: 'API_ERROR',
status: 404,
api: 'Git Gateway',
}),
);
});
it('should throw error on not ok response with msg property', async () => {
const api = new API({
apiRoot: 'https://site.netlify.com/.netlify/git/github',
tokenPromise: () => Promise.resolve('token'),
});
fetch.mockResolvedValue({
text: jest.fn().mockResolvedValue({ msg: 'some error' }),
ok: false,
status: 404,
headers: { get: () => '' },
});
await expect(api.request('some-path')).rejects.toThrow(
expect.objectContaining({
message: 'some error',
name: 'API_ERROR',
status: 404,
api: 'Git Gateway',
}),
);
});
});
describe('nextUrlProcessor', () => {
it('should re-write github url', () => {
const api = new API({
apiRoot: 'https://site.netlify.com/.netlify/git/github',
});
expect(api.nextUrlProcessor()('https://api.github.com/repositories/10000/pulls')).toEqual(
'https://site.netlify.com/.netlify/git/github/pulls',
);
});
});
});

View File

@@ -0,0 +1,277 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GitGatewayAuthenticationPage should render with identity error 1`] = `
<DocumentFragment>
.emotion-0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-flex-flow: column nowrap;
-webkit-flex-flow: column nowrap;
-ms-flex-flow: column nowrap;
flex-flow: column nowrap;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
gap: 50px;
height: 100vh;
}
.emotion-2 {
width: 300px;
height: auto;
}
.emotion-4 {
border: 0;
border-radius: 5px;
cursor: pointer;
box-shadow: 0 4px 12px 0 rgba(68, 74, 87, 0.15),0 1px 3px 0 rgba(68, 74, 87, 0.25);
height: 36px;
line-height: 36px;
font-weight: 500;
padding: 0 15px;
background-color: #798291;
color: #fff;
background-color: #313d3e;
color: #fff;
padding: 0 12px;
margin-top: 0;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
position: relative;
}
.emotion-4:focus,
.emotion-4:hover {
color: #fff;
background-color: #555a65;
}
.emotion-4[disabled] {
background-color: #eff0f4;
color: #798291;
cursor: default;
}
.emotion-7 {
display: inline-block;
line-height: 0;
width: 100px;
height: 100px;
-webkit-transform: rotate(0deg);
-moz-transform: rotate(0deg);
-ms-transform: rotate(0deg);
transform: rotate(0deg);
color: #c4c6d2;
position: absolute;
bottom: 10px;
}
.emotion-7 path:not(.no-fill),
.emotion-7 circle:not(.no-fill),
.emotion-7 polygon:not(.no-fill),
.emotion-7 rect:not(.no-fill) {
fill: currentColor;
}
.emotion-7 path.clipped {
fill: transparent;
}
.emotion-7 svg {
width: 100%;
height: 100%;
}
<section
class="emotion-0 emotion-1"
>
<span
class="emotion-2 emotion-3"
>
<img
alt="Logo"
src="logo_url"
/>
</span>
<button
class="emotion-4 emotion-5"
>
auth.loginWithNetlifyIdentity
</button>
<span
class="emotion-6 emotion-7 emotion-8"
>
<svg
fill="none"
height="90"
viewBox="0 0 335 90"
width="335"
xmlns="http://www.w3.org/2000/svg"
>
<path
class="no-fill"
d="M30.73.15 0 2.95l3.67 40.21 20.03-1.83-1.99-21.86 10.71-.98c10.61-.97 19.14 7.53 20.29 20.19l19.67-1.79C70.34 14.72 51.64-1.75 30.73.15ZM73.61 49.51c0 12.72-7.73 21.95-18.37 21.95H44.49V49.47H24.38v40.42h30.86c21.02 0 38.12-18.11 38.12-40.38v-.04H73.61v.04Z"
fill="#FF0082"
/>
<path
class="no-fill"
d="M131.65 23.71h20.01c14.41 0 24.29 9.09 24.29 23.06 0 13.97-9.88 23.06-24.29 23.06h-20.01V23.71Zm19.51 37.35c8.75 0 14.47-5.47 14.47-14.29s-5.73-14.29-14.47-14.29h-9.31v28.59h9.31v-.01ZM207.61 58.69l5.22 5.93c-3.15 3.75-7.87 5.73-13.97 5.73-11.7 0-19.32-7.71-19.32-18.25s7.68-18.25 18.12-18.25c9.56 0 17.43 6.59 17.49 17.92l-25.04 5.07c1.45 3.49 4.59 5.27 9 5.27 3.59 0 6.17-1.12 8.5-3.43v.01Zm-18.44-7.64 16.49-3.36c-.94-3.62-3.9-6.06-7.99-6.06-4.91 0-8.31 3.43-8.5 9.42ZM218.25 52.1c0-10.67 7.87-18.25 18.88-18.25 7.11 0 12.71 3.23 15.17 9.02l-7.61 4.28c-1.83-3.36-4.53-4.87-7.61-4.87-4.97 0-8.87 3.62-8.87 9.81s3.9 9.81 8.87 9.81c3.08 0 5.79-1.45 7.61-4.87l7.61 4.35c-2.45 5.67-8.05 8.96-15.17 8.96-11.01 0-18.88-7.58-18.88-18.25v.01ZM290.93 34.38v35.44h-9.38v-4.08c-2.45 3.1-6.04 4.61-10.57 4.61-9.57 0-16.93-7.11-16.93-18.25s7.36-18.25 16.93-18.25c4.15 0 7.68 1.38 10.13 4.28v-3.75h9.82ZM281.3 52.1c0-6.13-3.78-9.81-8.62-9.81S264 45.98 264 52.1c0 6.12 3.78 9.81 8.68 9.81s8.62-3.69 8.62-9.81ZM334.54 52.1c0 11.13-7.36 18.25-16.86 18.25-4.22 0-7.68-1.38-10.19-4.28V82.6h-9.82V34.38h9.38v4.08c2.45-3.1 6.1-4.61 10.63-4.61 9.5 0 16.86 7.11 16.86 18.25Zm-9.94 0c0-6.13-3.71-9.81-8.62-9.81-4.91 0-8.62 3.69-8.62 9.81 0 6.12 3.71 9.81 8.62 9.81 4.91 0 8.62-3.69 8.62-9.81Z"
fill="#000"
/>
</svg>
</span>
</section>
</DocumentFragment>
`;
exports[`GitGatewayAuthenticationPage should render with no identity error 1`] = `
<DocumentFragment>
.emotion-0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-flex-flow: column nowrap;
-webkit-flex-flow: column nowrap;
-ms-flex-flow: column nowrap;
flex-flow: column nowrap;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
gap: 50px;
height: 100vh;
}
.emotion-2 {
width: 300px;
height: auto;
}
.emotion-4 {
border: 0;
border-radius: 5px;
cursor: pointer;
box-shadow: 0 4px 12px 0 rgba(68, 74, 87, 0.15),0 1px 3px 0 rgba(68, 74, 87, 0.25);
height: 36px;
line-height: 36px;
font-weight: 500;
padding: 0 15px;
background-color: #798291;
color: #fff;
background-color: #313d3e;
color: #fff;
padding: 0 12px;
margin-top: 0;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
position: relative;
}
.emotion-4:focus,
.emotion-4:hover {
color: #fff;
background-color: #555a65;
}
.emotion-4[disabled] {
background-color: #eff0f4;
color: #798291;
cursor: default;
}
.emotion-7 {
display: inline-block;
line-height: 0;
width: 100px;
height: 100px;
-webkit-transform: rotate(0deg);
-moz-transform: rotate(0deg);
-ms-transform: rotate(0deg);
transform: rotate(0deg);
color: #c4c6d2;
position: absolute;
bottom: 10px;
}
.emotion-7 path:not(.no-fill),
.emotion-7 circle:not(.no-fill),
.emotion-7 polygon:not(.no-fill),
.emotion-7 rect:not(.no-fill) {
fill: currentColor;
}
.emotion-7 path.clipped {
fill: transparent;
}
.emotion-7 svg {
width: 100%;
height: 100%;
}
<section
class="emotion-0 emotion-1"
>
<span
class="emotion-2 emotion-3"
>
<img
alt="Logo"
src="logo_url"
/>
</span>
<button
class="emotion-4 emotion-5"
>
auth.loginWithNetlifyIdentity
</button>
<span
class="emotion-6 emotion-7 emotion-8"
>
<svg
fill="none"
height="90"
viewBox="0 0 335 90"
width="335"
xmlns="http://www.w3.org/2000/svg"
>
<path
class="no-fill"
d="M30.73.15 0 2.95l3.67 40.21 20.03-1.83-1.99-21.86 10.71-.98c10.61-.97 19.14 7.53 20.29 20.19l19.67-1.79C70.34 14.72 51.64-1.75 30.73.15ZM73.61 49.51c0 12.72-7.73 21.95-18.37 21.95H44.49V49.47H24.38v40.42h30.86c21.02 0 38.12-18.11 38.12-40.38v-.04H73.61v.04Z"
fill="#FF0082"
/>
<path
class="no-fill"
d="M131.65 23.71h20.01c14.41 0 24.29 9.09 24.29 23.06 0 13.97-9.88 23.06-24.29 23.06h-20.01V23.71Zm19.51 37.35c8.75 0 14.47-5.47 14.47-14.29s-5.73-14.29-14.47-14.29h-9.31v28.59h9.31v-.01ZM207.61 58.69l5.22 5.93c-3.15 3.75-7.87 5.73-13.97 5.73-11.7 0-19.32-7.71-19.32-18.25s7.68-18.25 18.12-18.25c9.56 0 17.43 6.59 17.49 17.92l-25.04 5.07c1.45 3.49 4.59 5.27 9 5.27 3.59 0 6.17-1.12 8.5-3.43v.01Zm-18.44-7.64 16.49-3.36c-.94-3.62-3.9-6.06-7.99-6.06-4.91 0-8.31 3.43-8.5 9.42ZM218.25 52.1c0-10.67 7.87-18.25 18.88-18.25 7.11 0 12.71 3.23 15.17 9.02l-7.61 4.28c-1.83-3.36-4.53-4.87-7.61-4.87-4.97 0-8.87 3.62-8.87 9.81s3.9 9.81 8.87 9.81c3.08 0 5.79-1.45 7.61-4.87l7.61 4.35c-2.45 5.67-8.05 8.96-15.17 8.96-11.01 0-18.88-7.58-18.88-18.25v.01ZM290.93 34.38v35.44h-9.38v-4.08c-2.45 3.1-6.04 4.61-10.57 4.61-9.57 0-16.93-7.11-16.93-18.25s7.36-18.25 16.93-18.25c4.15 0 7.68 1.38 10.13 4.28v-3.75h9.82ZM281.3 52.1c0-6.13-3.78-9.81-8.62-9.81S264 45.98 264 52.1c0 6.12 3.78 9.81 8.68 9.81s8.62-3.69 8.62-9.81ZM334.54 52.1c0 11.13-7.36 18.25-16.86 18.25-4.22 0-7.68-1.38-10.19-4.28V82.6h-9.82V34.38h9.38v4.08c2.45-3.1 6.1-4.61 10.63-4.61 9.5 0 16.86 7.11 16.86 18.25Zm-9.94 0c0-6.13-3.71-9.81-8.62-9.81-4.91 0-8.62 3.69-8.62 9.81 0 6.12 3.71 9.81 8.62 9.81 4.91 0 8.62-3.69 8.62-9.81Z"
fill="#000"
/>
</svg>
</span>
</section>
</DocumentFragment>
`;

View File

@@ -0,0 +1,629 @@
import GoTrue from 'gotrue-js';
import jwtDecode from 'jwt-decode';
import { get, pick, intersection } from 'lodash';
import ini from 'ini';
import {
APIError,
unsentRequest,
basename,
entriesByFiles,
parsePointerFile,
getLargeMediaPatternsFromGitAttributesFile,
getPointerFileForMediaFileObj,
getLargeMediaFilteredMediaFiles,
AccessTokenError,
PreviewState,
} from 'decap-cms-lib-util';
import { GitHubBackend } from 'decap-cms-backend-github';
import { GitLabBackend } from 'decap-cms-backend-gitlab';
import { BitbucketBackend, API as BitBucketAPI } from 'decap-cms-backend-bitbucket';
import GitHubAPI from './GitHubAPI';
import GitLabAPI from './GitLabAPI';
import AuthenticationPage from './AuthenticationPage';
import { getClient } from './netlify-lfs-client';
import type { Client } from './netlify-lfs-client';
import type {
ApiRequest,
AssetProxy,
PersistOptions,
Entry,
Cursor,
Implementation,
DisplayURL,
User,
Credentials,
Config,
ImplementationFile,
DisplayURLObject,
} from 'decap-cms-lib-util';
const STATUS_PAGE = 'https://www.netlifystatus.com';
const GIT_GATEWAY_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`;
const GIT_GATEWAY_OPERATIONAL_UNITS = ['Git Gateway'];
type GitGatewayStatus = {
id: string;
name: string;
status: string;
};
type NetlifyIdentity = {
logout: () => void;
currentUser: () => User;
on: (event: string, args: unknown) => void;
init: () => void;
store: { user: unknown; modal: { page: string }; saving: boolean };
};
type AuthClient = {
logout: () => void;
currentUser: () => unknown;
login?(email: string, password: string, remember?: boolean): Promise<unknown>;
clearStore: () => void;
};
declare global {
interface Window {
netlifyIdentity?: NetlifyIdentity;
}
}
const localHosts: Record<string, boolean> = {
localhost: true,
'127.0.0.1': true,
'0.0.0.0': true,
};
const defaults = {
identity: '/.netlify/identity',
gateway: '/.netlify/git',
largeMedia: '/.netlify/large-media',
};
function getEndpoint(endpoint: string, netlifySiteURL: string | null) {
if (
localHosts[document.location.host.split(':').shift() as string] &&
netlifySiteURL &&
endpoint.match(/^\/\.netlify\//)
) {
const parts = [];
if (netlifySiteURL) {
parts.push(netlifySiteURL);
if (!netlifySiteURL.match(/\/$/)) {
parts.push('/');
}
}
parts.push(endpoint.replace(/^\//, ''));
return parts.join('');
}
return endpoint;
}
// wait for identity widget to initialize
// force init on timeout
let initPromise = Promise.resolve() as Promise<unknown>;
if (window.netlifyIdentity) {
let initialized = false;
initPromise = Promise.race([
new Promise<void>(resolve => {
window.netlifyIdentity?.on('init', () => {
initialized = true;
resolve();
});
}),
new Promise(resolve => setTimeout(resolve, 2500)).then(() => {
if (!initialized) {
console.log('Manually initializing identity widget');
window.netlifyIdentity?.init();
}
}),
]);
}
interface NetlifyUser extends Credentials {
jwt: () => Promise<string>;
email: string;
user_metadata: { full_name: string; avatar_url: string };
}
async function apiGet(path: string) {
const apiRoot = 'https://api.netlify.com/api/v1/sites';
const response = await fetch(`${apiRoot}/${path}`).then(res => res.json());
return response;
}
export default class GitGateway implements Implementation {
config: Config;
api?: GitHubAPI | GitLabAPI | BitBucketAPI;
branch: string;
squashMerges: boolean;
cmsLabelPrefix: string;
mediaFolder: string;
transformImages: boolean;
gatewayUrl: string;
netlifyLargeMediaURL: string;
backendType: string | null;
apiUrl: string;
authClient?: AuthClient;
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.backend.branch?.trim() || 'master';
this.squashMerges = config.backend.squash_merges || false;
this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
this.mediaFolder = config.media_folder;
const { use_large_media_transforms_in_media_library: transformImages = true } = config.backend;
this.transformImages = transformImages;
const netlifySiteURL = localStorage.getItem('netlifySiteURL');
this.apiUrl = getEndpoint(config.backend.identity_url || defaults.identity, netlifySiteURL);
this.gatewayUrl = getEndpoint(config.backend.gateway_url || defaults.gateway, netlifySiteURL);
this.netlifyLargeMediaURL = getEndpoint(
config.backend.large_media_url || defaults.largeMedia,
netlifySiteURL,
);
const backendTypeRegex = /\/(github|gitlab|bitbucket)\/?$/;
const backendTypeMatches = this.gatewayUrl.match(backendTypeRegex);
if (backendTypeMatches) {
this.backendType = backendTypeMatches[1];
this.gatewayUrl = this.gatewayUrl.replace(backendTypeRegex, '');
} else {
this.backendType = null;
}
this.backend = null;
AuthenticationPage.authClient = () => this.getAuthClient();
}
isGitBackend() {
return true;
}
async status() {
const api = await fetch(GIT_GATEWAY_STATUS_ENDPOINT)
.then(res => res.json())
.then(res => {
return res['components']
.filter((statusComponent: GitGatewayStatus) =>
GIT_GATEWAY_OPERATIONAL_UNITS.includes(statusComponent.name),
)
.every((statusComponent: GitGatewayStatus) => statusComponent.status === 'operational');
})
.catch(e => {
console.warn('Failed getting Git Gateway status', e);
return true;
});
let auth = false;
// no need to check auth if api is down
if (api) {
auth =
(await this.tokenPromise?.()
.then(token => !!token)
.catch(e => {
console.warn('Failed getting Identity token', e);
return false;
})) || false;
}
return { auth: { status: auth }, api: { status: api, statusPage: STATUS_PAGE } };
}
async getAuthClient() {
if (this.authClient) {
return this.authClient;
}
await initPromise;
if (window.netlifyIdentity) {
this.authClient = {
logout: () => window.netlifyIdentity?.logout(),
currentUser: () => window.netlifyIdentity?.currentUser(),
clearStore: () => {
const store = window.netlifyIdentity?.store;
if (store) {
store.user = null;
store.modal.page = 'login';
store.saving = false;
}
},
};
} else {
const goTrue = new GoTrue({ APIUrl: this.apiUrl });
this.authClient = {
logout: () => {
const user = goTrue.currentUser();
if (user) {
return user.logout();
}
},
currentUser: () => goTrue.currentUser(),
login: goTrue.login.bind(goTrue),
clearStore: () => undefined,
};
}
return this.authClient;
}
requestFunction = (req: ApiRequest) =>
this.tokenPromise!()
.then(
token => unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }, req) as ApiRequest,
)
.then(unsentRequest.performRequest);
authenticate(credentials: Credentials) {
const user = credentials as NetlifyUser;
this.tokenPromise = async () => {
try {
const func = user.jwt.bind(user);
const token = await func();
return token;
} catch (error) {
throw new AccessTokenError(`Failed getting access token: ${error.message}`);
}
};
return this.tokenPromise!().then(async token => {
if (!this.backendType) {
const {
github_enabled: githubEnabled,
gitlab_enabled: gitlabEnabled,
bitbucket_enabled: bitbucketEnabled,
roles,
} = await unsentRequest
.fetchWithTimeout(`${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.`,
res.status,
'Git Gateway',
);
}
const body = await res.json();
if (!res.ok) {
throw new APIError(
`Git Gateway Error: ${body.message ? body.message : body}`,
res.status,
'Git Gateway',
);
}
return body;
});
this.acceptRoles = roles;
if (githubEnabled) {
this.backendType = 'github';
} else if (gitlabEnabled) {
this.backendType = 'gitlab';
} else if (bitbucketEnabled) {
this.backendType = 'bitbucket';
}
}
if (this.acceptRoles && this.acceptRoles.length > 0) {
const userRoles = get(jwtDecode(token), 'app_metadata.roles', []);
const validRole = intersection(userRoles, this.acceptRoles).length > 0;
if (!validRole) {
throw new Error("You don't have sufficient permissions to access Decap CMS");
}
}
const userData = {
name: user.user_metadata.full_name || user.email.split('@').shift()!,
email: user.email,
avatar_url: user.user_metadata.avatar_url,
metadata: user.user_metadata,
};
const apiConfig = {
apiRoot: `${this.gatewayUrl}/${this.backendType}`,
branch: this.branch,
tokenPromise: this.tokenPromise!,
commitAuthor: pick(userData, ['name', 'email']),
isLargeMedia: (filename: string) => this.isLargeMediaFile(filename),
squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
initialWorkflowStatus: this.options.initialWorkflowStatus,
};
if (this.backendType === 'github') {
this.api = new GitHubAPI(apiConfig);
this.backend = new GitHubBackend(this.config, { ...this.options, API: this.api });
} else if (this.backendType === 'gitlab') {
this.api = new GitLabAPI(apiConfig);
this.backend = new GitLabBackend(this.config, { ...this.options, API: this.api });
} else if (this.backendType === 'bitbucket') {
this.api = new BitBucketAPI({
...apiConfig,
requestFunction: this.requestFunction,
hasWriteAccess: async () => true,
});
this.backend = new BitbucketBackend(this.config, { ...this.options, API: this.api });
}
if (!(await this.api!.hasWriteAccess())) {
throw new Error("You don't have sufficient permissions to access Decap CMS");
}
return {
name: userData.name,
login: userData.email,
avatar_url: userData.avatar_url,
} as unknown as User;
});
}
async restoreUser() {
const client = await this.getAuthClient();
const user = client.currentUser();
if (!user) return Promise.reject();
return this.authenticate(user as Credentials);
}
authComponent() {
return AuthenticationPage;
}
async logout() {
const client = await this.getAuthClient();
try {
client.logout();
} catch (e) {
// due to a bug in the identity widget (gotrue-js actually) the store is not reset if logout fails
// TODO: remove after https://github.com/netlify/gotrue-js/pull/83 is merged
client.clearStore();
}
}
getToken() {
return this.tokenPromise!();
}
async entriesByFolder(folder: string, extension: string, depth: number) {
return this.backend!.entriesByFolder(folder, extension, depth);
}
allEntriesByFolder(folder: string, extension: string, depth: number, pathRegex?: RegExp) {
return this.backend!.allEntriesByFolder(folder, extension, depth, pathRegex);
}
entriesByFiles(files: ImplementationFile[]) {
return this.backend!.entriesByFiles(files);
}
getEntry(path: string) {
return this.backend!.getEntry(path);
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
return this.backend!.unpublishedEntryDataFile(collection, slug, path, id);
}
async isLargeMediaFile(path: string) {
const client = await this.getLargeMediaClient();
return client.enabled && client.matchPath(path);
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
const isLargeMedia = await this.isLargeMediaFile(path);
if (isLargeMedia) {
const branch = this.backend!.getBranch(collection, slug);
const { url, blob } = await this.getLargeMediaDisplayURL({ path, id }, branch);
return {
id,
name: basename(path),
path,
url,
displayURL: url,
file: new File([blob], basename(path)),
size: blob.size,
};
} else {
return this.backend!.unpublishedEntryMediaFile(collection, slug, path, id);
}
}
getMedia(mediaFolder = this.mediaFolder) {
return this.backend!.getMedia(mediaFolder);
}
// this method memoizes this._getLargeMediaClient so that there can
// only be one client at a time
getLargeMediaClient() {
if (this._largeMediaClientPromise) {
return this._largeMediaClientPromise;
}
this._largeMediaClientPromise = this._getLargeMediaClient();
return this._largeMediaClientPromise;
}
_getLargeMediaClient() {
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') || lfsURL.hostname.endsWith('netlify.app'),
}))
.catch((err: Error) => ({ enabled: false, 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 { err: null, patterns: [] as string[] };
} else {
return { err, patterns: [] as string[] };
}
});
return Promise.all([netlifyLargeMediaEnabledPromise, lfsPatternsPromise]).then(
([{ enabled: maybeEnabled }, { patterns, err: patternsErr }]) => {
const enabled = maybeEnabled && !patternsErr;
// We expect LFS patterns to exist when the .lfsconfig states
// that we're using Netlify Large Media
if (maybeEnabled && patternsErr) {
console.error(patternsErr);
}
return getClient({
enabled,
rootURL: this.netlifyLargeMediaURL,
makeAuthorizedRequest: this.requestFunction,
patterns,
transformImages: this.transformImages ? { nf_resize: 'fit', w: 560, h: 320 } : false,
});
},
);
}
async getLargeMediaDisplayURL(
{ path, id }: { path: string; id: string | null },
branch = this.branch,
) {
const readFile = (
path: string,
id: string | null | undefined,
{ parseText }: { parseText: boolean },
) => this.api!.readFile(path, id, { branch, parseText });
const items = await entriesByFiles(
[{ path, id }],
readFile,
this.api!.readFileMetadata.bind(this.api),
'Git-Gateway',
);
const entry = items[0];
const pointerFile = parsePointerFile(entry.data);
if (!pointerFile.sha) {
console.warn(`Failed parsing pointer file ${path}`);
return { url: path, blob: new Blob() };
}
const client = await this.getLargeMediaClient();
const { url, blob } = await client.getDownloadURL(pointerFile);
return { url, blob };
}
async getMediaDisplayURL(displayURL: DisplayURL) {
const { path, id } = displayURL as DisplayURLObject;
const isLargeMedia = await this.isLargeMediaFile(path);
if (isLargeMedia) {
const { url } = await this.getLargeMediaDisplayURL({ path, id });
return url;
}
if (typeof displayURL === 'string') {
return displayURL;
}
const url = await this.backend!.getMediaDisplayURL(displayURL);
return url;
}
async getMediaFile(path: string) {
const isLargeMedia = await this.isLargeMediaFile(path);
if (isLargeMedia) {
const { url, blob } = await this.getLargeMediaDisplayURL({ path, id: null });
return {
id: url,
name: basename(path),
path,
url,
displayURL: url,
file: new File([blob], basename(path)),
size: blob.size,
};
}
return this.backend!.getMediaFile(path);
}
async persistEntry(entry: Entry, options: PersistOptions) {
const client = await this.getLargeMediaClient();
if (client.enabled) {
const assets = await getLargeMediaFilteredMediaFiles(client, entry.assets);
return this.backend!.persistEntry({ ...entry, assets }, options);
} else {
return this.backend!.persistEntry(entry, options);
}
}
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
const { fileObj, path } = mediaFile;
const displayURL = fileObj ? URL.createObjectURL(fileObj) : '';
const client = await this.getLargeMediaClient();
const fixedPath = path.startsWith('/') ? path.slice(1) : path;
const isLargeMedia = await this.isLargeMediaFile(fixedPath);
if (isLargeMedia) {
const persistMediaArgument = await getPointerFileForMediaFileObj(
client,
fileObj as File,
path,
);
return {
...(await this.backend!.persistMedia(persistMediaArgument, options)),
displayURL,
};
}
return await this.backend!.persistMedia(mediaFile, options);
}
deleteFiles(paths: string[], commitMessage: string) {
return this.backend!.deleteFiles(paths, commitMessage);
}
async getDeployPreview(collection: string, slug: string) {
let preview = await this.backend!.getDeployPreview(collection, slug);
if (!preview) {
try {
// if the commit doesn't have a status, try to use Netlify API directly
// this is useful when builds are queue up in Netlify and don't have a commit status yet
// and only works with public logs at the moment
// TODO: get Netlify API Token and use it to access private logs
const siteId = new URL(localStorage.getItem('netlifySiteURL') || '').hostname;
const site = await apiGet(siteId);
const deploys: { state: string; commit_ref: string; deploy_url: string }[] = await apiGet(
`${site.id}/deploys?per_page=100`,
);
if (deploys.length > 0) {
const ref = await this.api!.getUnpublishedEntrySha(collection, slug);
const deploy = deploys.find(d => d.commit_ref === ref);
if (deploy) {
preview = {
status: deploy.state === 'ready' ? PreviewState.Success : PreviewState.Other,
url: deploy.deploy_url,
};
}
}
// eslint-disable-next-line no-empty
} catch (e) {}
}
return preview;
}
unpublishedEntries() {
return this.backend!.unpublishedEntries();
}
unpublishedEntry({ id, collection, slug }: { id?: string; collection?: string; slug?: string }) {
return this.backend!.unpublishedEntry({ id, collection, slug });
}
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
return this.backend!.updateUnpublishedEntryStatus(collection, slug, newStatus);
}
deleteUnpublishedEntry(collection: string, slug: string) {
return this.backend!.deleteUnpublishedEntry(collection, slug);
}
publishUnpublishedEntry(collection: string, slug: string) {
return this.backend!.publishUnpublishedEntry(collection, slug);
}
traverseCursor(cursor: Cursor, action: string) {
return this.backend!.traverseCursor!(cursor, action);
}
}

View File

@@ -0,0 +1,8 @@
import GitGatewayBackend from './implementation';
import AuthenticationPage from './AuthenticationPage';
export const DecapCmsBackendGitGateway = {
GitGatewayBackend,
AuthenticationPage,
};
export { GitGatewayBackend, AuthenticationPage };

View File

@@ -0,0 +1,180 @@
import { flow, fromPairs, map } from 'lodash/fp';
import { isPlainObject, isEmpty } from 'lodash';
import minimatch from 'minimatch';
import { unsentRequest } from 'decap-cms-lib-util';
import type { ApiRequest, PointerFile } from 'decap-cms-lib-util';
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 function matchPath({ patterns }: ClientConfig, path: string) {
return 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',
};
async function resourceExists(
{ 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
}
function getTransofrmationsParams(t: boolean | ImageTransformations) {
if (isPlainObject(t) && !isEmpty(t)) {
const { nf_resize: resize, w, h } = t as ImageTransformations;
return `?nf_resize=${resize}&w=${w}&h=${h}`;
}
return '';
}
async function getDownloadURL(
{ rootURL, transformImages: t, makeAuthorizedRequest }: ClientConfig,
{ sha }: PointerFile,
) {
try {
const transformation = getTransofrmationsParams(t);
const transformedPromise = makeAuthorizedRequest(`${rootURL}/origin/${sha}${transformation}`);
const [transformed, original] = await Promise.all([
transformedPromise,
// if transformation is defined, we need to load the original so we have the correct meta data
transformation ? makeAuthorizedRequest(`${rootURL}/origin/${sha}`) : transformedPromise,
]);
if (!transformed.ok) {
const error = await transformed.json();
throw new Error(
`Failed getting large media for sha '${sha}': '${error.code} - ${error.msg}'`,
);
}
const transformedBlob = await transformed.blob();
const url = URL.createObjectURL(transformedBlob);
return { url, blob: transformation ? await original.blob() : transformedBlob };
} catch (error) {
console.error(error);
return { url: '', blob: new Blob() };
}
}
function uploadOperation(objects: PointerFile[]) {
return {
operation: 'upload',
transfers: ['basic'],
objects: objects.map(({ sha, ...rest }) => ({ ...rest, oid: sha })),
};
}
async function getResourceUploadURLs(
{
rootURL,
makeAuthorizedRequest,
}: { rootURL: string; makeAuthorizedRequest: MakeAuthorizedRequest },
pointerFiles: PointerFile[],
) {
const response = await makeAuthorizedRequest({
url: `${rootURL}/objects/batch`,
method: 'POST',
headers: defaultContentHeaders,
body: JSON.stringify(uploadOperation(pointerFiles)),
});
const { objects } = await response.json();
const uploadUrls = objects.map(
(object: { error?: { message: string }; actions: { upload: { href: string } } }) => {
if (object.error) {
throw new Error(object.error.message);
}
return object.actions.upload.href;
},
);
return uploadUrls;
}
function uploadBlob(uploadURL: string, blob: Blob) {
return unsentRequest.fetchWithTimeout(uploadURL, {
method: 'PUT',
body: blob,
});
}
async function uploadResource(
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
function configureFn(config: ClientConfig, fn: Function) {
return (...args: unknown[]) => fn(config, ...args);
}
const clientFns: Record<string, Function> = {
resourceExists,
getResourceUploadURLs,
getDownloadURL,
uploadResource,
matchPath,
};
export type Client = {
resourceExists: (pointer: PointerFile) => Promise<boolean | undefined>;
getResourceUploadURLs: (objects: PointerFile[]) => Promise<string>;
getDownloadURL: (pointer: PointerFile) => Promise<{ url: string; blob: Blob }>;
uploadResource: (pointer: PointerFile, blob: Blob) => Promise<string>;
matchPath: (path: string) => boolean;
patterns: string[];
enabled: boolean;
};
export function 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;
}