feat: Gitea backend refactoring (#833)
This commit is contained in:
parent
77f5a51be8
commit
6cb1098b40
@ -1,9 +1,5 @@
|
|||||||
import { Base64 } from 'js-base64';
|
import { Base64 } from 'js-base64';
|
||||||
import initial from 'lodash/initial';
|
import { trimStart, trim, result, partial, last, initial } from 'lodash';
|
||||||
import last from 'lodash/last';
|
|
||||||
import partial from 'lodash/partial';
|
|
||||||
import result from 'lodash/result';
|
|
||||||
import trim from 'lodash/trim';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
APIError,
|
APIError,
|
||||||
@ -22,12 +18,12 @@ import type { ApiRequest, FetchError } from '@staticcms/core/lib/util';
|
|||||||
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
|
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
|
||||||
import type { Semaphore } from 'semaphore';
|
import type { Semaphore } from 'semaphore';
|
||||||
import type {
|
import type {
|
||||||
|
FilesResponse,
|
||||||
GitGetBlobResponse,
|
GitGetBlobResponse,
|
||||||
GitGetTreeResponse,
|
GitGetTreeResponse,
|
||||||
GiteaUser,
|
GiteaUser,
|
||||||
ReposGetResponse,
|
ReposGetResponse,
|
||||||
ReposListCommitsResponse,
|
ReposListCommitsResponse,
|
||||||
ContentsResponse,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const API_NAME = 'Gitea';
|
export const API_NAME = 'Gitea';
|
||||||
@ -40,6 +36,20 @@ export interface Config {
|
|||||||
originRepo?: string;
|
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 {
|
interface MetaDataObjects {
|
||||||
entry: { path: string; sha: string };
|
entry: { path: string; sha: string };
|
||||||
files: MediaFile[];
|
files: MediaFile[];
|
||||||
@ -76,13 +86,6 @@ type MediaFile = {
|
|||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Diff = {
|
|
||||||
path: string;
|
|
||||||
newFile: boolean;
|
|
||||||
sha: string;
|
|
||||||
binary: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class API {
|
export default class API {
|
||||||
apiRoot: string;
|
apiRoot: string;
|
||||||
token: string;
|
token: string;
|
||||||
@ -120,7 +123,7 @@ export default class API {
|
|||||||
|
|
||||||
static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Static CMS';
|
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) {
|
if (!this._userPromise) {
|
||||||
this._userPromise = this.getUser();
|
this._userPromise = this.getUser();
|
||||||
}
|
}
|
||||||
@ -365,50 +368,53 @@ export default class API {
|
|||||||
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
|
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const files: (DataFile | AssetProxy)[] = mediaFiles.concat(dataFiles as any);
|
const files: (DataFile | AssetProxy)[] = mediaFiles.concat(dataFiles as any);
|
||||||
for (const file of files) {
|
const operations = await this.getChangeFileOperations(files, this.branch);
|
||||||
const item: { raw?: string; sha?: string; toBase64?: () => Promise<string> } = file;
|
return this.changeFiles(operations, options);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateBlob(
|
async changeFiles(operations: ChangeFileOperation[], options: PersistOptions) {
|
||||||
contentBase64: string,
|
return (await this.request(`${this.repoURL}/contents`, {
|
||||||
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}`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
branch: this.branch,
|
branch: this.branch,
|
||||||
content: contentBase64,
|
files: operations,
|
||||||
message: options.commitMessage,
|
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 } = {}) {
|
async getFileSha(path: string, { repoURL = this.repoURL, branch = this.branch } = {}) {
|
||||||
@ -434,15 +440,18 @@ export default class API {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteFiles(paths: string[], message: string) {
|
async deleteFiles(paths: string[], message: string) {
|
||||||
for (const file of paths) {
|
const operations: ChangeFileOperation[] = await Promise.all(
|
||||||
const meta: ContentsResponse = await this.request(`${this.repoURL}/contents/${file}`, {
|
paths.map(async path => {
|
||||||
method: 'GET',
|
const sha = await this.getFileSha(path);
|
||||||
});
|
|
||||||
await this.request(`${this.repoURL}/contents/${file}`, {
|
return {
|
||||||
method: 'DELETE',
|
operation: FileOperation.DELETE,
|
||||||
body: JSON.stringify({ branch: this.branch, message, sha: meta.sha, signoff: false }),
|
path,
|
||||||
});
|
sha,
|
||||||
}
|
} as ChangeFileOperation;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return this.changeFiles(operations, { commitMessage: message });
|
||||||
}
|
}
|
||||||
|
|
||||||
toBase64(str: string) {
|
toBase64(str: string) {
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { Gitea as GiteaIcon } from '@styled-icons/simple-icons/Gitea';
|
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 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 { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
|
||||||
import type { MouseEvent } from 'react';
|
import type { MouseEvent } from 'react';
|
||||||
@ -10,36 +10,45 @@ import type { MouseEvent } from 'react';
|
|||||||
const GiteaAuthenticationPage = ({
|
const GiteaAuthenticationPage = ({
|
||||||
inProgress = false,
|
inProgress = false,
|
||||||
config,
|
config,
|
||||||
base_url,
|
clearHash,
|
||||||
siteId,
|
|
||||||
authEndpoint,
|
|
||||||
onLogin,
|
onLogin,
|
||||||
t,
|
t,
|
||||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const auth = useMemo(() => {
|
||||||
|
const { base_url = 'https://try.gitea.io', app_id = '' } = config.backend;
|
||||||
|
|
||||||
|
const clientSizeAuth = new PkceAuthenticator({
|
||||||
|
base_url,
|
||||||
|
auth_endpoint: 'login/oauth/authorize',
|
||||||
|
app_id,
|
||||||
|
auth_token_endpoint: 'login/oauth/access_token',
|
||||||
|
clearHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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(
|
const handleLogin = useCallback(
|
||||||
(e: MouseEvent<HTMLButtonElement>) => {
|
(e: MouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const cfg = {
|
auth.authenticate({ scope: 'repository' }, err => {
|
||||||
base_url,
|
|
||||||
site_id: document.location.host.split(':')[0] === 'localhost' ? 'cms.netlify.com' : siteId,
|
|
||||||
auth_endpoint: authEndpoint,
|
|
||||||
};
|
|
||||||
const auth = new NetlifyAuthenticator(cfg);
|
|
||||||
|
|
||||||
const { auth_scope: authScope = '' } = config.backend;
|
|
||||||
|
|
||||||
const scope = authScope || 'repo';
|
|
||||||
auth.authenticate({ provider: 'gitea', scope }, (err, data) => {
|
|
||||||
if (err) {
|
if (err) {
|
||||||
setLoginError(err.toString());
|
setLoginError(err.toString());
|
||||||
} else if (data) {
|
return;
|
||||||
onLogin(data);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[authEndpoint, base_url, config.backend, onLogin, siteId],
|
[auth],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -101,12 +101,24 @@ describe('gitea API', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('persistFiles', () => {
|
describe('persistFiles', () => {
|
||||||
it('should check if file exists and create a new file', async () => {
|
it('should create a new commit', async () => {
|
||||||
const api = new API({ branch: 'master', repo: 'owner/repo' });
|
const api = new API({ branch: 'master', repo: 'owner/repo' });
|
||||||
|
|
||||||
const responses = {
|
const responses = {
|
||||||
'/repos/owner/repo/contents/content/posts/new-post.md': () => ({
|
'/repos/owner/repo/git/trees/master:content%2Fposts': () => {
|
||||||
|
return { tree: [{ path: 'update-post.md', sha: 'old-sha' }] };
|
||||||
|
},
|
||||||
|
|
||||||
|
'/repos/owner/repo/contents': () => ({
|
||||||
commit: { sha: 'new-sha' },
|
commit: { sha: 'new-sha' },
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
path: 'content/posts/new-post.md',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'content/posts/update-post.md',
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
mockAPI(api, responses);
|
mockAPI(api, responses);
|
||||||
@ -115,85 +127,142 @@ describe('gitea API', () => {
|
|||||||
dataFiles: [
|
dataFiles: [
|
||||||
{
|
{
|
||||||
slug: 'entry',
|
slug: 'entry',
|
||||||
sha: 'abc',
|
|
||||||
path: 'content/posts/new-post.md',
|
path: 'content/posts/new-post.md',
|
||||||
raw: 'content',
|
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();
|
|
||||||
const api = new API({ branch: 'master', repo: 'owner/repo' });
|
|
||||||
|
|
||||||
const responses = {
|
|
||||||
'/repos/owner/repo/git/trees/master:content%2Fposts': () => {
|
|
||||||
return { tree: [{ path: 'update-post.md', sha: 'old-sha' }] };
|
|
||||||
},
|
|
||||||
|
|
||||||
'/repos/owner/repo/contents/content/posts/update-post.md': () => {
|
|
||||||
return { commit: { sha: 'updated-sha' } };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
mockAPI(api, responses);
|
|
||||||
|
|
||||||
const entry = {
|
|
||||||
dataFiles: [
|
|
||||||
{
|
{
|
||||||
slug: 'entry',
|
slug: 'entry',
|
||||||
sha: 'abc',
|
sha: 'old-sha',
|
||||||
path: 'content/posts/update-post.md',
|
path: 'content/posts/update-post.md',
|
||||||
raw: 'content',
|
raw: 'content',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
assets: [],
|
assets: [],
|
||||||
};
|
};
|
||||||
|
await expect(
|
||||||
await api.persistFiles(entry.dataFiles, entry.assets, {
|
api.persistFiles(entry.dataFiles, entry.assets, {
|
||||||
commitMessage: 'commitMessage',
|
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([
|
expect((api.request as jest.Mock).mock.calls[0]).toEqual([
|
||||||
'/repos/owner/repo/git/trees/master:content%2Fposts',
|
'/repos/owner/repo/git/trees/master:content%2Fposts',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect((api.request as jest.Mock).mock.calls[1]).toEqual([
|
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({
|
body: JSON.stringify({
|
||||||
branch: 'master',
|
branch: 'master',
|
||||||
content: Base64.encode(entry.dataFiles[0].raw),
|
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',
|
message: 'commitMessage',
|
||||||
sha: 'old-sha',
|
|
||||||
signoff: false,
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
@ -77,7 +77,7 @@ export default class Gitea implements BackendClass {
|
|||||||
this.api = this.options.API || null;
|
this.api = this.options.API || null;
|
||||||
this.repo = this.originRepo = config.backend.repo || '';
|
this.repo = this.originRepo = config.backend.repo || '';
|
||||||
this.branch = config.backend.branch?.trim() || 'main';
|
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.token = '';
|
||||||
this.mediaFolder = config.media_folder;
|
this.mediaFolder = config.media_folder;
|
||||||
this.lock = asyncLock();
|
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.');
|
throw new Error('Your Gitea user account does not have access to this repo.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(user);
|
||||||
|
|
||||||
// Authorized 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() {
|
logout() {
|
||||||
|
@ -46,13 +46,13 @@ export type GiteaOrganization = {
|
|||||||
website: string;
|
website: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReposListCommitsResponseItemCommitUser = {
|
type CommitUser = {
|
||||||
date: string;
|
date: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReposListCommitsResponseItemCommitMeta = {
|
type CommitMeta = {
|
||||||
created: string;
|
created: string;
|
||||||
sha: string;
|
sha: string;
|
||||||
url: string;
|
url: string;
|
||||||
@ -73,10 +73,10 @@ type PayloadCommitVerification = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ReposListCommitsResponseItemCommit = {
|
type ReposListCommitsResponseItemCommit = {
|
||||||
author: ReposListCommitsResponseItemCommitUser;
|
author: CommitUser;
|
||||||
committer: ReposListCommitsResponseItemCommitUser;
|
committer: CommitUser;
|
||||||
message: string;
|
message: string;
|
||||||
tree: ReposListCommitsResponseItemCommitMeta;
|
tree: CommitMeta;
|
||||||
url: string;
|
url: string;
|
||||||
verification: PayloadCommitVerification;
|
verification: PayloadCommitVerification;
|
||||||
};
|
};
|
||||||
@ -183,7 +183,7 @@ type ReposListCommitsResponseItem = {
|
|||||||
created: string;
|
created: string;
|
||||||
files: Array<ReposListCommitsResponseItemCommitAffectedFiles>;
|
files: Array<ReposListCommitsResponseItemCommitAffectedFiles>;
|
||||||
html_url: string;
|
html_url: string;
|
||||||
parents: Array<ReposListCommitsResponseItemCommitMeta>;
|
parents: Array<CommitMeta>;
|
||||||
sha: string;
|
sha: string;
|
||||||
stats: ReposListCommitsResponseItemCommitStats;
|
stats: ReposListCommitsResponseItemCommitStats;
|
||||||
url: string;
|
url: string;
|
||||||
@ -217,18 +217,13 @@ export type GitGetTreeResponse = {
|
|||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GiteaIdentity = {
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FileLinksResponse = {
|
type FileLinksResponse = {
|
||||||
git: string;
|
git: string;
|
||||||
html: string;
|
html: string;
|
||||||
self: string;
|
self: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ContentsResponse = {
|
type ContentsResponse = {
|
||||||
_links: FileLinksResponse;
|
_links: FileLinksResponse;
|
||||||
content?: string | null;
|
content?: string | null;
|
||||||
download_url: string;
|
download_url: string;
|
||||||
@ -245,3 +240,21 @@ export type ContentsResponse = {
|
|||||||
type: string;
|
type: string;
|
||||||
url: 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;
|
||||||
|
};
|
||||||
|
@ -570,7 +570,14 @@ export interface MediaLibraryConfig {
|
|||||||
folder_support?: boolean;
|
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';
|
export type MapWidgetType = 'Point' | 'LineString' | 'Polygon';
|
||||||
|
|
||||||
|
@ -120,19 +120,25 @@ export default class PkceAuthenticator {
|
|||||||
if ('code' in params) {
|
if ('code' in params) {
|
||||||
const code = params.code;
|
const code = params.code;
|
||||||
const authURL = new URL(this.auth_token_url);
|
const authURL = new URL(this.auth_token_url);
|
||||||
authURL.searchParams.set('client_id', this.appID);
|
|
||||||
authURL.searchParams.set('code', code ?? '');
|
const response = await fetch(authURL.href, {
|
||||||
authURL.searchParams.set('grant_type', 'authorization_code');
|
method: 'POST',
|
||||||
authURL.searchParams.set(
|
body: JSON.stringify({
|
||||||
'redirect_uri',
|
client_id: this.appID,
|
||||||
document.location.origin + document.location.pathname,
|
code: code ?? '',
|
||||||
);
|
grant_type: 'authorization_code',
|
||||||
authURL.searchParams.set('code_verifier', getCodeVerifier() ?? '');
|
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
|
//no need for verifier code so remove
|
||||||
clearCodeVerifier();
|
clearCodeVerifier();
|
||||||
|
|
||||||
const response = await fetch(authURL.href, { method: 'POST' });
|
|
||||||
const data = await response.json();
|
|
||||||
cb(null, { token: data.access_token, ...data });
|
cb(null, { token: data.access_token, ...data });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 |
|
| 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 |
|
| 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 |
|
| 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. |
|
| 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
|
## Creating a New Backend
|
||||||
|
@ -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.
|
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
|
## 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. 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:
|
||||||
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:
|
|
||||||
|
|
||||||
<CodeTabs>
|
<CodeTabs>
|
||||||
```yaml
|
```yaml
|
||||||
backend:
|
backend:
|
||||||
name: gitea
|
name: gitea
|
||||||
repo: owner-name/repo-name # Path to your Gitea repository
|
repo: owner-name/repo-name # Path to your Gitea repository
|
||||||
base_url: https://oauth.example.com # URL of your OAuth provider
|
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
|
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
|
# optional, defaults to main
|
||||||
# branch: main
|
# branch: main
|
||||||
```
|
```
|
||||||
@ -35,8 +34,9 @@ backend:
|
|||||||
backend: {
|
backend: {
|
||||||
name: 'gitea',
|
name: 'gitea',
|
||||||
repo: 'owner-name/repo-name', // Path to your Gitea repository
|
repo: 'owner-name/repo-name', // Path to your Gitea repository
|
||||||
base_url: 'https://oauth.example.com', // URL of your OAuth provider
|
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
|
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
|
// optional, defaults to main
|
||||||
// branch: 'main'
|
// branch: 'main'
|
||||||
},
|
},
|
||||||
|
Loading…
x
Reference in New Issue
Block a user