feat: Gitea backend refactoring (#833)

This commit is contained in:
Denys Konovalov 2023-06-06 17:45:05 +02:00 committed by GitHub
parent 77f5a51be8
commit 6cb1098b40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 293 additions and 173 deletions

View File

@ -1,9 +1,5 @@
import { Base64 } from 'js-base64';
import initial from 'lodash/initial';
import last from 'lodash/last';
import partial from 'lodash/partial';
import result from 'lodash/result';
import trim from 'lodash/trim';
import { trimStart, trim, result, partial, last, initial } from 'lodash';
import {
APIError,
@ -22,12 +18,12 @@ import type { ApiRequest, FetchError } from '@staticcms/core/lib/util';
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
import type { Semaphore } from 'semaphore';
import type {
FilesResponse,
GitGetBlobResponse,
GitGetTreeResponse,
GiteaUser,
ReposGetResponse,
ReposListCommitsResponse,
ContentsResponse,
} from './types';
export const API_NAME = 'Gitea';
@ -40,6 +36,20 @@ export interface Config {
originRepo?: string;
}
enum FileOperation {
CREATE = 'create',
DELETE = 'delete',
UPDATE = 'update',
}
export interface ChangeFileOperation {
content?: string;
from_path?: string;
path: string;
operation: FileOperation;
sha?: string;
}
interface MetaDataObjects {
entry: { path: string; sha: string };
files: MediaFile[];
@ -76,13 +86,6 @@ type MediaFile = {
path: string;
};
export type Diff = {
path: string;
newFile: boolean;
sha: string;
binary: boolean;
};
export default class API {
apiRoot: string;
token: string;
@ -120,7 +123,7 @@ export default class API {
static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Static CMS';
user(): Promise<{ full_name: string; login: string }> {
user(): Promise<{ full_name: string; login: string; avatar_url: string }> {
if (!this._userPromise) {
this._userPromise = this.getUser();
}
@ -365,50 +368,53 @@ export default class API {
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const files: (DataFile | AssetProxy)[] = mediaFiles.concat(dataFiles as any);
for (const file of files) {
const item: { raw?: string; sha?: string; toBase64?: () => Promise<string> } = file;
const contentBase64 = await result(
item,
'toBase64',
partial(this.toBase64, item.raw as string),
);
try {
const oldSha = await this.getFileSha(file.path);
await this.updateBlob(contentBase64, file, options, oldSha!);
} catch {
await this.createBlob(contentBase64, file, options);
}
}
const operations = await this.getChangeFileOperations(files, this.branch);
return this.changeFiles(operations, options);
}
async updateBlob(
contentBase64: string,
file: AssetProxy | DataFile,
options: PersistOptions,
oldSha: string,
) {
await this.request(`${this.repoURL}/contents/${file.path}`, {
method: 'PUT',
body: JSON.stringify({
branch: this.branch,
content: contentBase64,
message: options.commitMessage,
sha: oldSha,
signoff: false,
}),
});
}
async createBlob(contentBase64: string, file: AssetProxy | DataFile, options: PersistOptions) {
await this.request(`${this.repoURL}/contents/${file.path}`, {
async changeFiles(operations: ChangeFileOperation[], options: PersistOptions) {
return (await this.request(`${this.repoURL}/contents`, {
method: 'POST',
body: JSON.stringify({
branch: this.branch,
content: contentBase64,
files: operations,
message: options.commitMessage,
signoff: false,
}),
});
})) as FilesResponse;
}
async getChangeFileOperations(files: { path: string; newPath?: string }[], branch: string) {
const items: ChangeFileOperation[] = await Promise.all(
files.map(async file => {
const content = await result(
file,
'toBase64',
partial(this.toBase64, (file as DataFile).raw),
);
let sha;
let operation;
let from_path;
let path = trimStart(file.path, '/');
try {
sha = await this.getFileSha(file.path, { branch });
operation = FileOperation.UPDATE;
from_path = file.newPath && path;
path = file.newPath ? trimStart(file.newPath, '/') : path;
} catch {
sha = undefined;
operation = FileOperation.CREATE;
}
return {
operation,
content,
path,
from_path,
sha,
} as ChangeFileOperation;
}),
);
return items;
}
async getFileSha(path: string, { repoURL = this.repoURL, branch = this.branch } = {}) {
@ -434,15 +440,18 @@ export default class API {
}
async deleteFiles(paths: string[], message: string) {
for (const file of paths) {
const meta: ContentsResponse = await this.request(`${this.repoURL}/contents/${file}`, {
method: 'GET',
});
await this.request(`${this.repoURL}/contents/${file}`, {
method: 'DELETE',
body: JSON.stringify({ branch: this.branch, message, sha: meta.sha, signoff: false }),
});
}
const operations: ChangeFileOperation[] = await Promise.all(
paths.map(async path => {
const sha = await this.getFileSha(path);
return {
operation: FileOperation.DELETE,
path,
sha,
} as ChangeFileOperation;
}),
);
return this.changeFiles(operations, { commitMessage: message });
}
toBase64(str: string) {

View File

@ -1,8 +1,8 @@
import { Gitea as GiteaIcon } from '@styled-icons/simple-icons/Gitea';
import React, { useCallback, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import Login from '@staticcms/core/components/login/Login';
import { NetlifyAuthenticator } from '@staticcms/core/lib/auth';
import { PkceAuthenticator } from '@staticcms/core/lib/auth';
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
@ -10,36 +10,45 @@ import type { MouseEvent } from 'react';
const GiteaAuthenticationPage = ({
inProgress = false,
config,
base_url,
siteId,
authEndpoint,
clearHash,
onLogin,
t,
}: TranslatedProps<AuthenticationPageProps>) => {
const [loginError, setLoginError] = useState<string | null>(null);
const handleLogin = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
const cfg = {
const auth = useMemo(() => {
const { base_url = 'https://try.gitea.io', app_id = '' } = config.backend;
const clientSizeAuth = new PkceAuthenticator({
base_url,
site_id: document.location.host.split(':')[0] === 'localhost' ? 'cms.netlify.com' : siteId,
auth_endpoint: authEndpoint,
};
const auth = new NetlifyAuthenticator(cfg);
auth_endpoint: 'login/oauth/authorize',
app_id,
auth_token_endpoint: 'login/oauth/access_token',
clearHash,
});
const { auth_scope: authScope = '' } = config.backend;
const scope = authScope || 'repo';
auth.authenticate({ provider: 'gitea', scope }, (err, data) => {
// Complete authentication if we were redirected back to from the provider.
clientSizeAuth.completeAuth((err, data) => {
if (err) {
setLoginError(err.toString());
} else if (data) {
onLogin(data);
}
});
return clientSizeAuth;
}, [clearHash, config.backend, onLogin]);
const handleLogin = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
auth.authenticate({ scope: 'repository' }, err => {
if (err) {
setLoginError(err.toString());
return;
}
});
},
[authEndpoint, base_url, config.backend, onLogin, siteId],
[auth],
);
return (

View File

@ -101,53 +101,7 @@ describe('gitea API', () => {
});
describe('persistFiles', () => {
it('should check if file exists and create a new file', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const responses = {
'/repos/owner/repo/contents/content/posts/new-post.md': () => ({
commit: { sha: 'new-sha' },
}),
};
mockAPI(api, responses);
const entry = {
dataFiles: [
{
slug: 'entry',
sha: 'abc',
path: 'content/posts/new-post.md',
raw: 'content',
},
],
assets: [],
};
await api.persistFiles(entry.dataFiles, entry.assets, {
commitMessage: 'commitMessage',
newEntry: true,
});
expect(api.request).toHaveBeenCalledTimes(2);
expect((api.request as jest.Mock).mock.calls[0]).toEqual([
'/repos/owner/repo/git/trees/master:content%2Fposts',
]);
expect((api.request as jest.Mock).mock.calls[1]).toEqual([
'/repos/owner/repo/contents/content/posts/new-post.md',
{
method: 'POST',
body: JSON.stringify({
branch: 'master',
content: Base64.encode(entry.dataFiles[0].raw),
message: 'commitMessage',
signoff: false,
}),
},
]);
});
it('should get the file sha and update the file', async () => {
jest.clearAllMocks();
it('should create a new commit', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const responses = {
@ -155,9 +109,17 @@ describe('gitea API', () => {
return { tree: [{ path: 'update-post.md', sha: 'old-sha' }] };
},
'/repos/owner/repo/contents/content/posts/update-post.md': () => {
return { commit: { sha: 'updated-sha' } };
'/repos/owner/repo/contents': () => ({
commit: { sha: 'new-sha' },
files: [
{
path: 'content/posts/new-post.md',
},
{
path: 'content/posts/update-post.md',
},
],
}),
};
mockAPI(api, responses);
@ -165,35 +127,142 @@ describe('gitea API', () => {
dataFiles: [
{
slug: 'entry',
sha: 'abc',
path: 'content/posts/new-post.md',
raw: 'content',
},
{
slug: 'entry',
sha: 'old-sha',
path: 'content/posts/update-post.md',
raw: 'content',
},
],
assets: [],
};
await api.persistFiles(entry.dataFiles, entry.assets, {
await expect(
api.persistFiles(entry.dataFiles, entry.assets, {
commitMessage: 'commitMessage',
newEntry: false,
newEntry: true,
}),
).resolves.toEqual({
commit: { sha: 'new-sha' },
files: [
{
path: 'content/posts/new-post.md',
},
{
path: 'content/posts/update-post.md',
},
],
});
expect(api.request).toHaveBeenCalledTimes(2);
expect(api.request).toHaveBeenCalledTimes(3);
expect((api.request as jest.Mock).mock.calls[0]).toEqual([
'/repos/owner/repo/git/trees/master:content%2Fposts',
]);
expect((api.request as jest.Mock).mock.calls[1]).toEqual([
'/repos/owner/repo/contents/content/posts/update-post.md',
'/repos/owner/repo/git/trees/master:content%2Fposts',
]);
expect((api.request as jest.Mock).mock.calls[2]).toEqual([
'/repos/owner/repo/contents',
{
method: 'PUT',
method: 'POST',
body: JSON.stringify({
branch: 'master',
files: [
{
operation: 'create',
content: Base64.encode(entry.dataFiles[0].raw),
path: entry.dataFiles[0].path,
},
{
operation: 'update',
content: Base64.encode(entry.dataFiles[1].raw),
path: entry.dataFiles[1].path,
sha: entry.dataFiles[1].sha,
},
],
message: 'commitMessage',
}),
},
]);
});
});
describe('deleteFiles', () => {
it('should check if files exist and delete them', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const responses = {
'/repos/owner/repo/git/trees/master:content%2Fposts': () => {
return {
tree: [
{ path: 'delete-post-1.md', sha: 'old-sha-1' },
{ path: 'delete-post-2.md', sha: 'old-sha-2' },
],
};
},
'/repos/owner/repo/contents': () => ({
commit: { sha: 'new-sha' },
files: [
{
path: 'content/posts/delete-post-1.md',
},
{
path: 'content/posts/delete-post-2.md',
},
],
}),
};
mockAPI(api, responses);
const deleteFiles = ['content/posts/delete-post-1.md', 'content/posts/delete-post-2.md'];
await expect(api.deleteFiles(deleteFiles, 'commitMessage')).resolves.toEqual({
commit: { sha: 'new-sha' },
files: [
{
path: 'content/posts/delete-post-1.md',
},
{
path: 'content/posts/delete-post-2.md',
},
],
});
expect(api.request).toHaveBeenCalledTimes(3);
expect((api.request as jest.Mock).mock.calls[0]).toEqual([
'/repos/owner/repo/git/trees/master:content%2Fposts',
]);
expect((api.request as jest.Mock).mock.calls[1]).toEqual([
'/repos/owner/repo/git/trees/master:content%2Fposts',
]);
expect((api.request as jest.Mock).mock.calls[2]).toEqual([
'/repos/owner/repo/contents',
{
method: 'POST',
body: JSON.stringify({
branch: 'master',
files: [
{
operation: 'delete',
path: deleteFiles[0],
sha: 'old-sha-1',
},
{
operation: 'delete',
path: deleteFiles[1],
sha: 'old-sha-2',
},
],
message: 'commitMessage',
sha: 'old-sha',
signoff: false,
}),
},
]);

View File

@ -77,7 +77,7 @@ export default class Gitea implements BackendClass {
this.api = this.options.API || null;
this.repo = this.originRepo = config.backend.repo || '';
this.branch = config.backend.branch?.trim() || 'main';
this.apiRoot = config.backend.api_root || 'https://gitea.com/api/v1';
this.apiRoot = config.backend.api_root || 'https://try.gitea.io/api/v1';
this.token = '';
this.mediaFolder = config.media_folder;
this.lock = asyncLock();
@ -173,8 +173,15 @@ export default class Gitea implements BackendClass {
throw new Error('Your Gitea user account does not have access to this repo.');
}
console.log(user);
// Authorized user
return { ...user, token: state.token as string };
return {
name: user.full_name,
login: user.login,
avatar_url: user.avatar_url,
token: state.token as string,
};
}
logout() {

View File

@ -46,13 +46,13 @@ export type GiteaOrganization = {
website: string;
};
type ReposListCommitsResponseItemCommitUser = {
type CommitUser = {
date: string;
email: string;
name: string;
};
type ReposListCommitsResponseItemCommitMeta = {
type CommitMeta = {
created: string;
sha: string;
url: string;
@ -73,10 +73,10 @@ type PayloadCommitVerification = {
};
type ReposListCommitsResponseItemCommit = {
author: ReposListCommitsResponseItemCommitUser;
committer: ReposListCommitsResponseItemCommitUser;
author: CommitUser;
committer: CommitUser;
message: string;
tree: ReposListCommitsResponseItemCommitMeta;
tree: CommitMeta;
url: string;
verification: PayloadCommitVerification;
};
@ -183,7 +183,7 @@ type ReposListCommitsResponseItem = {
created: string;
files: Array<ReposListCommitsResponseItemCommitAffectedFiles>;
html_url: string;
parents: Array<ReposListCommitsResponseItemCommitMeta>;
parents: Array<CommitMeta>;
sha: string;
stats: ReposListCommitsResponseItemCommitStats;
url: string;
@ -217,18 +217,13 @@ export type GitGetTreeResponse = {
url: string;
};
export type GiteaIdentity = {
email: string;
name: string;
};
type FileLinksResponse = {
git: string;
html: string;
self: string;
};
export type ContentsResponse = {
type ContentsResponse = {
_links: FileLinksResponse;
content?: string | null;
download_url: string;
@ -245,3 +240,21 @@ export type ContentsResponse = {
type: string;
url: string;
};
type FileCommitResponse = {
author: CommitUser;
committer: CommitUser;
created: string;
html_url: string;
message: string;
parents: Array<CommitMeta>;
sha: string;
tree: CommitMeta;
url: string;
};
export type FilesResponse = {
commit: FileCommitResponse;
content: Array<ContentsResponse>;
verification: PayloadCommitVerification;
};

View File

@ -570,7 +570,14 @@ export interface MediaLibraryConfig {
folder_support?: boolean;
}
export type BackendType = 'git-gateway' | 'github' | 'gitlab' | 'bitbucket' | 'test-repo' | 'proxy';
export type BackendType =
| 'git-gateway'
| 'github'
| 'gitlab'
| 'gitea'
| 'bitbucket'
| 'test-repo'
| 'proxy';
export type MapWidgetType = 'Point' | 'LineString' | 'Polygon';

View File

@ -120,19 +120,25 @@ export default class PkceAuthenticator {
if ('code' in params) {
const code = params.code;
const authURL = new URL(this.auth_token_url);
authURL.searchParams.set('client_id', this.appID);
authURL.searchParams.set('code', code ?? '');
authURL.searchParams.set('grant_type', 'authorization_code');
authURL.searchParams.set(
'redirect_uri',
document.location.origin + document.location.pathname,
);
authURL.searchParams.set('code_verifier', getCodeVerifier() ?? '');
const response = await fetch(authURL.href, {
method: 'POST',
body: JSON.stringify({
client_id: this.appID,
code: code ?? '',
grant_type: 'authorization_code',
redirect_uri: document.location.origin + document.location.pathname,
code_verifier: getCodeVerifier() ?? '',
}),
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
});
const data = await response.json();
//no need for verifier code so remove
clearCodeVerifier();
const response = await fetch(authURL.href, { method: 'POST' });
const data = await response.json();
cb(null, { token: data.access_token, ...data });
}
}

View File

@ -17,7 +17,7 @@ Individual backends provide their own configuration documentation, but there are
| branch | string | `main` | _Optional_. The branch where published content is stored. All CMS commits and PRs are made to this branch |
| api_root | string | GitHub<br />`https://api.github.com`<br /><br />GitLab<br/>`https://gitlab.com/api/v4`<br /><br />Bitbucket<br />`https://api.bitbucket.org/2.0`<br /><br />Gitea<br />`https://try.gitea.io/api/v1` | _Optional_. The API endpoint. Only necessary in certain cases, like with GitHub Enterprise or self-hosted GitLab |
| site_domain | string | `location.hostname`<br /><br />On `localhost`<br />`cms.netlify.com` | _Optional_. Sets the `site_id` query param sent to the API endpoint. Non-Netlify auth setups will often need to set this for local development to work properly |
| base_url | string | GitHub or Bitbucket<br />`https://api.netlify.com`<br /><br />GitLab<br />`https://gitlab.com` | _Optional_. OAuth client hostname (just the base domain, no path). **Required** when using an external OAuth server or self-hosted GitLab/Gitea |
| base_url | string | GitHub or Bitbucket<br />`https://api.netlify.com`<br /><br />GitLab<br />`https://gitlab.com`<br /><br />Gitea<br />`https://try.gitea.io` | _Optional_. OAuth client hostname (just the base domain, no path). **Required** when using an external OAuth server or self-hosted GitLab/Gitea |
| auth_endpoint | string | GitHub or Bitbucket<br />`auth`<br /><br />GitLab<br />`oauth/authorize` | _Optional_. Path to append to `base_url` for authentication requests. |
## Creating a New Backend

View File

@ -9,24 +9,23 @@ beta: true
For repositories stored on Gitea, the `gitea` backend allows CMS users to log in directly with their Gitea account. Note that all users must have push access to your content repository for this to work.
<Alert severity="warning">Because of the [lack](https://github.com/go-gitea/gitea/issues/14619) of a Gitea API endpoint for multifile commits, when using this backend, separate commits are created for every changed file. Please make sure this is handled correctly by your CI.</Alert>
Please note that only Gitea **1.20** and upwards is supported due to API limitations in previous versions.
## Authentication
Because Gitea requires a server for authentication and Netlify doesn't support Gitea, a custom OAuth provider needs to be used for basic Gitea authentication.
With Gitea's PKCE authorization, users can authenticate with Gitea directly from the client. To do this:
To enable basic Gitea authentication:
1. Setup an own OAuth provider, for example with [Teabag](https://github.com/denyskon/teabag).
2. Add the following lines to your Static CMS `config` file:
1. Add your Static CMS instance as an OAuth application in your user/organization settings or through the admin panel of your Gitea instance. Please make sure to uncheck the **Confidential Client** checkbox. For the **Redirect URIs**, enter the addresses where you access Static CMS, for example, `https://www.mysite.com/admin/`.
2. Gitea provides you with a **Client ID**. Copy it and insert it into your `config` file along with the other options:
<CodeTabs>
```yaml
backend:
name: gitea
repo: owner-name/repo-name # Path to your Gitea repository
base_url: https://oauth.example.com # URL of your OAuth provider
api_root: https://gitea.example.com/api/v1 # API url of your Gitea instance
app_id: your-client-id # The Client ID provided by Gitea
api_root: https://gitea.example.com/api/v1 # API URL of your Gitea instance
base_url: https://gitea.example.com # Root URL of your Gitea instance
# optional, defaults to main
# branch: main
```
@ -35,8 +34,9 @@ backend:
backend: {
name: 'gitea',
repo: 'owner-name/repo-name', // Path to your Gitea repository
base_url: 'https://oauth.example.com', // URL of your OAuth provider
api_root: 'https://gitea.example.com/api/v1' // API url of your Gitea instance
app_id: 'your-client-id', // The Client ID provided by Gitea
api_root: 'https://gitea.example.com/api/v1', // API URL of your Gitea instance
base_url: 'https://gitea.example.com', // Root URL of your Gitea instance
// optional, defaults to main
// branch: 'main'
},