feat: gitea backend (#325)
This commit is contained in:
parent
e50048dad6
commit
63496b07eb
437
packages/core/src/backends/gitea/API.ts
Normal file
437
packages/core/src/backends/gitea/API.ts
Normal file
@ -0,0 +1,437 @@
|
||||
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 {
|
||||
APIError,
|
||||
basename,
|
||||
generateContentKey,
|
||||
getAllResponses,
|
||||
localForage,
|
||||
parseContentKey,
|
||||
readFileMetadata,
|
||||
requestWithBackoff,
|
||||
unsentRequest,
|
||||
} from '@staticcms/core/lib/util';
|
||||
|
||||
import type { DataFile, PersistOptions } from '@staticcms/core/interface';
|
||||
import type { ApiRequest, FetchError } from '@staticcms/core/lib/util';
|
||||
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
|
||||
import type { Semaphore } from 'semaphore';
|
||||
import type {
|
||||
GitGetBlobResponse,
|
||||
GitGetTreeResponse,
|
||||
GiteaUser,
|
||||
ReposGetResponse,
|
||||
ReposListCommitsResponse,
|
||||
ContentsResponse,
|
||||
} from './types';
|
||||
|
||||
export const API_NAME = 'Gitea';
|
||||
|
||||
export interface Config {
|
||||
apiRoot?: string;
|
||||
token?: string;
|
||||
branch?: string;
|
||||
repo?: string;
|
||||
originRepo?: string;
|
||||
}
|
||||
|
||||
interface MetaDataObjects {
|
||||
entry: { path: string; sha: string };
|
||||
files: MediaFile[];
|
||||
}
|
||||
|
||||
export interface Metadata {
|
||||
type: string;
|
||||
objects: MetaDataObjects;
|
||||
branch: string;
|
||||
status: string;
|
||||
collection: string;
|
||||
commitMessage: string;
|
||||
version?: string;
|
||||
user: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
timeStamp: string;
|
||||
}
|
||||
|
||||
export interface BlobArgs {
|
||||
sha: string;
|
||||
repoURL: string;
|
||||
parseText: boolean;
|
||||
}
|
||||
|
||||
type Param = string | number | undefined;
|
||||
|
||||
export type Options = RequestInit & {
|
||||
params?: Record<string, Param | Record<string, Param> | string[]>;
|
||||
};
|
||||
|
||||
type MediaFile = {
|
||||
sha: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type Diff = {
|
||||
path: string;
|
||||
newFile: boolean;
|
||||
sha: string;
|
||||
binary: boolean;
|
||||
};
|
||||
|
||||
export default class API {
|
||||
apiRoot: string;
|
||||
token: string;
|
||||
branch: string;
|
||||
repo: string;
|
||||
originRepo: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
originRepoOwner: string;
|
||||
originRepoName: string;
|
||||
repoURL: string;
|
||||
originRepoURL: string;
|
||||
|
||||
_userPromise?: Promise<GiteaUser>;
|
||||
_metadataSemaphore?: Semaphore;
|
||||
|
||||
commitAuthor?: {};
|
||||
|
||||
constructor(config: Config) {
|
||||
this.apiRoot = config.apiRoot || 'https://try.gitea.io/api/v1';
|
||||
this.token = config.token || '';
|
||||
this.branch = config.branch || 'main';
|
||||
this.repo = config.repo || '';
|
||||
this.originRepo = config.originRepo || this.repo;
|
||||
this.repoURL = `/repos/${this.repo}`;
|
||||
this.originRepoURL = `/repos/${this.originRepo}`;
|
||||
|
||||
const [repoParts, originRepoParts] = [this.repo.split('/'), this.originRepo.split('/')];
|
||||
this.repoOwner = repoParts[0];
|
||||
this.repoName = repoParts[1];
|
||||
|
||||
this.originRepoOwner = originRepoParts[0];
|
||||
this.originRepoName = originRepoParts[1];
|
||||
}
|
||||
|
||||
static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Static CMS';
|
||||
|
||||
user(): Promise<{ full_name: string; login: string }> {
|
||||
if (!this._userPromise) {
|
||||
this._userPromise = this.getUser();
|
||||
}
|
||||
return this._userPromise;
|
||||
}
|
||||
|
||||
getUser() {
|
||||
return this.request('/user') as Promise<GiteaUser>;
|
||||
}
|
||||
|
||||
async hasWriteAccess() {
|
||||
try {
|
||||
const result: ReposGetResponse = await this.request(this.repoURL);
|
||||
// update config repoOwner to avoid case sensitivity issues with Gitea
|
||||
this.repoOwner = result.owner.login;
|
||||
return result.permissions.push;
|
||||
} catch (error) {
|
||||
console.error('Problem fetching repo data from Gitea');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
// no op
|
||||
}
|
||||
|
||||
requestHeaders(headers = {}) {
|
||||
const baseHeader: Record<string, string> = {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
...headers,
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
baseHeader.Authorization = `token ${this.token}`;
|
||||
return Promise.resolve(baseHeader);
|
||||
}
|
||||
|
||||
return Promise.resolve(baseHeader);
|
||||
}
|
||||
|
||||
async parseJsonResponse(response: Response) {
|
||||
const json = await response.json();
|
||||
if (!response.ok) {
|
||||
return Promise.reject(json);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
urlFor(path: string, options: Options) {
|
||||
const params = [];
|
||||
if (options.params) {
|
||||
for (const key in options.params) {
|
||||
params.push(`${key}=${encodeURIComponent(options.params[key] as string)}`);
|
||||
}
|
||||
}
|
||||
if (params.length) {
|
||||
path += `?${params.join('&')}`;
|
||||
}
|
||||
return this.apiRoot + path;
|
||||
}
|
||||
|
||||
parseResponse(response: Response) {
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (contentType && contentType.match(/json/)) {
|
||||
return this.parseJsonResponse(response);
|
||||
}
|
||||
const textPromise = response.text().then(text => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(text);
|
||||
}
|
||||
return text;
|
||||
});
|
||||
return textPromise;
|
||||
}
|
||||
|
||||
handleRequestError(error: FetchError, responseStatus: number) {
|
||||
throw new APIError(error.message, responseStatus, API_NAME);
|
||||
}
|
||||
|
||||
buildRequest(req: ApiRequest) {
|
||||
return req;
|
||||
}
|
||||
|
||||
async request(
|
||||
path: string,
|
||||
options: Options = {},
|
||||
parser = (response: Response) => this.parseResponse(response),
|
||||
) {
|
||||
options = { cache: 'no-cache', ...options };
|
||||
const headers = await this.requestHeaders(options.headers || {});
|
||||
const url = this.urlFor(path, options);
|
||||
let responseStatus = 500;
|
||||
|
||||
try {
|
||||
const req = unsentRequest.fromFetchArguments(url, {
|
||||
...options,
|
||||
headers,
|
||||
}) as unknown as ApiRequest;
|
||||
const response = await requestWithBackoff(this, req);
|
||||
responseStatus = response.status;
|
||||
const parsedResponse = await parser(response);
|
||||
return parsedResponse;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
return this.handleRequestError(error, responseStatus);
|
||||
}
|
||||
}
|
||||
|
||||
nextUrlProcessor() {
|
||||
return (url: string) => url;
|
||||
}
|
||||
|
||||
async requestAllPages<T>(url: string, options: Options = {}) {
|
||||
options = { cache: 'no-cache', ...options };
|
||||
const headers = await this.requestHeaders(options.headers || {});
|
||||
const processedURL = this.urlFor(url, options);
|
||||
const allResponses = await getAllResponses(
|
||||
processedURL,
|
||||
{ ...options, headers },
|
||||
'next',
|
||||
this.nextUrlProcessor(),
|
||||
);
|
||||
const pages: T[][] = await Promise.all(
|
||||
allResponses.map((res: Response) => this.parseResponse(res)),
|
||||
);
|
||||
return ([] as T[]).concat(...pages);
|
||||
}
|
||||
|
||||
generateContentKey(collectionName: string, slug: string) {
|
||||
return generateContentKey(collectionName, slug);
|
||||
}
|
||||
|
||||
parseContentKey(contentKey: string) {
|
||||
return parseContentKey(contentKey);
|
||||
}
|
||||
|
||||
async readFile(
|
||||
path: string,
|
||||
sha?: string | null,
|
||||
{
|
||||
branch = this.branch,
|
||||
repoURL = this.repoURL,
|
||||
parseText = true,
|
||||
}: {
|
||||
branch?: string;
|
||||
repoURL?: string;
|
||||
parseText?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!sha) {
|
||||
sha = await this.getFileSha(path, { repoURL, branch });
|
||||
}
|
||||
const content = await this.fetchBlobContent({ sha: sha as string, repoURL, parseText });
|
||||
return content;
|
||||
}
|
||||
|
||||
async readFileMetadata(path: string, sha: string | null | undefined) {
|
||||
const fetchFileMetadata = async () => {
|
||||
try {
|
||||
const result: ReposListCommitsResponse = await this.request(
|
||||
`${this.originRepoURL}/commits`,
|
||||
{
|
||||
params: { path, sha: this.branch },
|
||||
},
|
||||
);
|
||||
const { commit } = result[0];
|
||||
return {
|
||||
author: commit.author.name || commit.author.email,
|
||||
updatedOn: commit.author.date,
|
||||
};
|
||||
} catch (e) {
|
||||
return { author: '', updatedOn: '' };
|
||||
}
|
||||
};
|
||||
const fileMetadata = await readFileMetadata(sha, fetchFileMetadata, localForage);
|
||||
return fileMetadata;
|
||||
}
|
||||
|
||||
async fetchBlobContent({ sha, repoURL, parseText }: BlobArgs) {
|
||||
const result: GitGetBlobResponse = await this.request(`${repoURL}/git/blobs/${sha}`, {
|
||||
cache: 'force-cache',
|
||||
});
|
||||
|
||||
if (parseText) {
|
||||
// treat content as a utf-8 string
|
||||
const content = Base64.decode(result.content);
|
||||
return content;
|
||||
} else {
|
||||
// treat content as binary and convert to blob
|
||||
const content = Base64.atob(result.content);
|
||||
const byteArray = new Uint8Array(content.length);
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
byteArray[i] = content.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray]);
|
||||
return blob;
|
||||
}
|
||||
}
|
||||
|
||||
async listFiles(
|
||||
path: string,
|
||||
{ repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
|
||||
): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
|
||||
const folder = trim(path, '/');
|
||||
try {
|
||||
const result: GitGetTreeResponse = await this.request(
|
||||
`${repoURL}/git/trees/${branch}:${encodeURIComponent(folder)}`,
|
||||
{
|
||||
// Gitea API supports recursive=1 for getting the entire recursive tree
|
||||
// or omitting it to get the non-recursive tree
|
||||
params: depth > 1 ? { recursive: 1 } : {},
|
||||
},
|
||||
);
|
||||
return (
|
||||
result.tree
|
||||
// filter only files and up to the required depth
|
||||
.filter(
|
||||
file =>
|
||||
file.type === 'blob' && decodeURIComponent(file.path).split('/').length <= depth,
|
||||
)
|
||||
.map(file => ({
|
||||
type: file.type,
|
||||
id: file.sha,
|
||||
name: basename(file.path),
|
||||
path: `${folder}/${file.path}`,
|
||||
size: file.size!,
|
||||
}))
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
if (err && err.status === 404) {
|
||||
console.info('This 404 was expected and handled appropriately.');
|
||||
return [];
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[mediaFiles, dataFiles].forEach(async list => {
|
||||
list.forEach(async file => {
|
||||
const item: { raw?: string; sha?: string; toBase64?: () => Promise<string> } = file;
|
||||
const contentBase64 = await result(
|
||||
item,
|
||||
'toBase64',
|
||||
partial(this.toBase64, item.raw as string),
|
||||
);
|
||||
if (options.newEntry) {
|
||||
await this.request(`${this.repoURL}/contents/${file.path}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
branch: this.branch,
|
||||
content: contentBase64,
|
||||
message: options.commitMessage,
|
||||
signoff: false,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
const oldSha = await this.getFileSha(file.path);
|
||||
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 getFileSha(path: string, { repoURL = this.repoURL, branch = this.branch } = {}) {
|
||||
/**
|
||||
* We need to request the tree first to get the SHA. We use extended SHA-1
|
||||
* syntax (<rev>:<path>) to get a blob from a tree without having to recurse
|
||||
* through the tree.
|
||||
*/
|
||||
|
||||
const pathArray = path.split('/');
|
||||
const filename = last(pathArray);
|
||||
const directory = initial(pathArray).join('/');
|
||||
const fileDataPath = encodeURIComponent(directory);
|
||||
const fileDataURL = `${repoURL}/git/trees/${branch}:${fileDataPath}`;
|
||||
|
||||
const result: GitGetTreeResponse = await this.request(fileDataURL);
|
||||
const file = result.tree.find(file => file.path === filename);
|
||||
if (file) {
|
||||
return file.sha;
|
||||
} else {
|
||||
console.error('File not found');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFiles(paths: string[], message: string) {
|
||||
paths.forEach(async file => {
|
||||
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 }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
toBase64(str: string) {
|
||||
return Promise.resolve(Base64.encode(str));
|
||||
}
|
||||
}
|
64
packages/core/src/backends/gitea/AuthenticationPage.tsx
Normal file
64
packages/core/src/backends/gitea/AuthenticationPage.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import AuthenticationPage from '@staticcms/core/components/UI/AuthenticationPage';
|
||||
import Icon from '@staticcms/core/components/UI/Icon';
|
||||
import { NetlifyAuthenticator } from '@staticcms/core/lib/auth';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`;
|
||||
|
||||
const GiteaAuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
base_url,
|
||||
siteId,
|
||||
authEndpoint,
|
||||
onLogin,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
const cfg = {
|
||||
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) {
|
||||
setLoginError(err.toString());
|
||||
} else if (data) {
|
||||
onLogin(data);
|
||||
}
|
||||
});
|
||||
},
|
||||
[authEndpoint, base_url, config.backend, onLogin, siteId],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={loginError}
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
icon={<LoginButtonIcon type="gitea" />}
|
||||
buttonContent={t('auth.loginWithGitea')}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GiteaAuthenticationPage;
|
267
packages/core/src/backends/gitea/__tests__/API.spec.ts
Normal file
267
packages/core/src/backends/gitea/__tests__/API.spec.ts
Normal file
@ -0,0 +1,267 @@
|
||||
import { Base64 } from 'js-base64';
|
||||
|
||||
import API from '../API';
|
||||
|
||||
import type { Options } from '../API';
|
||||
|
||||
describe('gitea API', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
global.fetch = jest.fn().mockRejectedValue(new Error('should not call fetch inside tests'));
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function mockAPI(api: API, responses: Record<string, (options: Options) => any>) {
|
||||
api.request = jest.fn().mockImplementation((path, options = {}) => {
|
||||
const normalizedPath = path.indexOf('?') !== -1 ? path.slice(0, path.indexOf('?')) : path;
|
||||
const response = responses[normalizedPath];
|
||||
return typeof response === 'function'
|
||||
? Promise.resolve(response(options))
|
||||
: Promise.reject(new Error(`No response for path '${normalizedPath}'`));
|
||||
});
|
||||
}
|
||||
|
||||
describe('request', () => {
|
||||
const fetch = jest.fn();
|
||||
beforeEach(() => {
|
||||
global.fetch = fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch url with authorization header', async () => {
|
||||
const api = new API({ branch: 'gh-pages', repo: 'my-repo', token: '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://try.gitea.io/api/v1/some-path', {
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
Authorization: 'token token',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error on not ok response', async () => {
|
||||
const api = new API({ branch: 'gt-pages', repo: 'my-repo', token: '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: 'Gitea',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow overriding requestHeaders to return a promise ', async () => {
|
||||
const api = new API({ branch: 'gt-pages', repo: 'my-repo', token: 'token' });
|
||||
|
||||
api.requestHeaders = jest.fn().mockResolvedValue({
|
||||
Authorization: 'promise-token',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
});
|
||||
|
||||
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://try.gitea.io/api/v1/some-path', {
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
Authorization: 'promise-token',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistFiles', () => {
|
||||
it('should 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(1);
|
||||
|
||||
expect((api.request as jest.Mock).mock.calls[0]).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',
|
||||
sha: 'abc',
|
||||
path: 'content/posts/update-post.md',
|
||||
raw: 'content',
|
||||
},
|
||||
],
|
||||
assets: [],
|
||||
};
|
||||
|
||||
await api.persistFiles(entry.dataFiles, entry.assets, {
|
||||
commitMessage: 'commitMessage',
|
||||
newEntry: false,
|
||||
});
|
||||
|
||||
expect(api.request).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect((api.request as jest.Mock).mock.calls[0]).toEqual([
|
||||
'/repos/owner/repo/git/trees/master:content%2Fposts',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listFiles', () => {
|
||||
it('should get files by depth', async () => {
|
||||
const api = new API({ branch: 'master', repo: 'owner/repo' });
|
||||
|
||||
const tree = [
|
||||
{
|
||||
path: 'post.md',
|
||||
type: 'blob',
|
||||
},
|
||||
{
|
||||
path: 'dir1',
|
||||
type: 'tree',
|
||||
},
|
||||
{
|
||||
path: 'dir1/nested-post.md',
|
||||
type: 'blob',
|
||||
},
|
||||
{
|
||||
path: 'dir1/dir2',
|
||||
type: 'tree',
|
||||
},
|
||||
{
|
||||
path: 'dir1/dir2/nested-post.md',
|
||||
type: 'blob',
|
||||
},
|
||||
];
|
||||
api.request = jest.fn().mockResolvedValue({ tree });
|
||||
|
||||
await expect(api.listFiles('posts', { depth: 1 })).resolves.toEqual([
|
||||
{
|
||||
path: 'posts/post.md',
|
||||
type: 'blob',
|
||||
name: 'post.md',
|
||||
},
|
||||
]);
|
||||
expect(api.request).toHaveBeenCalledTimes(1);
|
||||
expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:posts', {
|
||||
params: {},
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
await expect(api.listFiles('posts', { depth: 2 })).resolves.toEqual([
|
||||
{
|
||||
path: 'posts/post.md',
|
||||
type: 'blob',
|
||||
name: 'post.md',
|
||||
},
|
||||
{
|
||||
path: 'posts/dir1/nested-post.md',
|
||||
type: 'blob',
|
||||
name: 'nested-post.md',
|
||||
},
|
||||
]);
|
||||
expect(api.request).toHaveBeenCalledTimes(1);
|
||||
expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:posts', {
|
||||
params: { recursive: 1 },
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
await expect(api.listFiles('posts', { depth: 3 })).resolves.toEqual([
|
||||
{
|
||||
path: 'posts/post.md',
|
||||
type: 'blob',
|
||||
name: 'post.md',
|
||||
},
|
||||
{
|
||||
path: 'posts/dir1/nested-post.md',
|
||||
type: 'blob',
|
||||
name: 'nested-post.md',
|
||||
},
|
||||
{
|
||||
path: 'posts/dir1/dir2/nested-post.md',
|
||||
type: 'blob',
|
||||
name: 'nested-post.md',
|
||||
},
|
||||
]);
|
||||
expect(api.request).toHaveBeenCalledTimes(1);
|
||||
expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:posts', {
|
||||
params: { recursive: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,289 @@
|
||||
import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from '@staticcms/core/lib/util/Cursor';
|
||||
import GiteaImplementation from '../implementation';
|
||||
|
||||
import type { Config, UnknownField } from '@staticcms/core';
|
||||
import type API from '../API';
|
||||
import type { AssetProxy } from '@staticcms/core/valueObjects';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
declare const global: any;
|
||||
|
||||
describe('gitea backend implementation', () => {
|
||||
const config = {
|
||||
backend: {
|
||||
repo: 'owner/repo',
|
||||
api_root: 'https://try.gitea.io/api/v1',
|
||||
},
|
||||
} as Config<UnknownField>;
|
||||
|
||||
const createObjectURL = jest.fn();
|
||||
global.URL = {
|
||||
createObjectURL,
|
||||
};
|
||||
|
||||
createObjectURL.mockReturnValue('displayURL');
|
||||
|
||||
beforeAll(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('persistMedia', () => {
|
||||
const persistFiles = jest.fn();
|
||||
const mockAPI = {
|
||||
persistFiles,
|
||||
} as unknown as API;
|
||||
|
||||
persistFiles.mockImplementation((_, files: (AssetProxy & { sha: string })[]) => {
|
||||
files.forEach((file, index) => {
|
||||
file.sha = `${index}`;
|
||||
});
|
||||
});
|
||||
|
||||
it('should persist media file', async () => {
|
||||
const giteaImplementation = new GiteaImplementation(config);
|
||||
giteaImplementation.api = mockAPI;
|
||||
|
||||
const mediaFile = {
|
||||
fileObj: { size: 100, name: 'image.png' },
|
||||
path: '/media/image.png',
|
||||
} as AssetProxy;
|
||||
|
||||
expect.assertions(5);
|
||||
await expect(
|
||||
giteaImplementation.persistMedia(mediaFile, { commitMessage: 'Persisting media' }),
|
||||
).resolves.toEqual({
|
||||
id: '0',
|
||||
name: 'image.png',
|
||||
size: 100,
|
||||
displayURL: 'displayURL',
|
||||
path: 'media/image.png',
|
||||
});
|
||||
|
||||
expect(persistFiles).toHaveBeenCalledTimes(1);
|
||||
expect(persistFiles).toHaveBeenCalledWith([], [mediaFile], {
|
||||
commitMessage: 'Persisting media',
|
||||
});
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
||||
expect(createObjectURL).toHaveBeenCalledWith(mediaFile.fileObj);
|
||||
});
|
||||
|
||||
it('should log and throw error on "persistFiles" error', async () => {
|
||||
const giteaImplementation = new GiteaImplementation(config);
|
||||
giteaImplementation.api = mockAPI;
|
||||
|
||||
const error = new Error('failed to persist files');
|
||||
persistFiles.mockRejectedValue(error);
|
||||
|
||||
const mediaFile = {
|
||||
fileObj: { size: 100 },
|
||||
path: '/media/image.png',
|
||||
} as AssetProxy;
|
||||
|
||||
expect.assertions(5);
|
||||
await expect(
|
||||
giteaImplementation.persistMedia(mediaFile, { commitMessage: 'Persisting media' }),
|
||||
).rejects.toThrowError(error);
|
||||
|
||||
expect(persistFiles).toHaveBeenCalledTimes(1);
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(0);
|
||||
expect(console.error).toHaveBeenCalledTimes(1);
|
||||
expect(console.error).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entriesByFolder', () => {
|
||||
const listFiles = jest.fn();
|
||||
const readFile = jest.fn();
|
||||
const readFileMetadata = jest.fn(() => Promise.resolve({ author: '', updatedOn: '' }));
|
||||
|
||||
const mockAPI = {
|
||||
listFiles,
|
||||
readFile,
|
||||
readFileMetadata,
|
||||
originRepoURL: 'originRepoURL',
|
||||
} as unknown as API;
|
||||
|
||||
it('should return entries and cursor', async () => {
|
||||
const giteaImplementation = new GiteaImplementation(config);
|
||||
giteaImplementation.api = mockAPI;
|
||||
|
||||
const files = [];
|
||||
const count = 1501;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = `${i}`.padStart(`${count}`.length, '0');
|
||||
files.push({
|
||||
id,
|
||||
path: `posts/post-${id}.md`,
|
||||
});
|
||||
}
|
||||
|
||||
listFiles.mockResolvedValue(files);
|
||||
readFile.mockImplementation((_path, id) => Promise.resolve(`${id}`));
|
||||
|
||||
const expectedEntries = files
|
||||
.slice(0, 20)
|
||||
.map(({ id, path }) => ({ data: id, file: { path, id, author: '', updatedOn: '' } }));
|
||||
|
||||
const expectedCursor = Cursor.create({
|
||||
actions: ['next', 'last'],
|
||||
meta: { page: 1, count, pageSize: 20, pageCount: 76 },
|
||||
data: { files },
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(expectedEntries as any)[CURSOR_COMPATIBILITY_SYMBOL] = expectedCursor;
|
||||
|
||||
const result = await giteaImplementation.entriesByFolder('posts', 'md', 1);
|
||||
|
||||
expect(result).toEqual(expectedEntries);
|
||||
expect(listFiles).toHaveBeenCalledTimes(1);
|
||||
expect(listFiles).toHaveBeenCalledWith('posts', { depth: 1, repoURL: 'originRepoURL' });
|
||||
expect(readFile).toHaveBeenCalledTimes(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('traverseCursor', () => {
|
||||
const listFiles = jest.fn();
|
||||
const readFile = jest.fn((_path, id) => Promise.resolve(`${id}`));
|
||||
const readFileMetadata = jest.fn(() => Promise.resolve({}));
|
||||
|
||||
const mockAPI = {
|
||||
listFiles,
|
||||
readFile,
|
||||
originRepoURL: 'originRepoURL',
|
||||
readFileMetadata,
|
||||
} as unknown as API;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const files: any[] = [];
|
||||
const count = 1501;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = `${i}`.padStart(`${count}`.length, '0');
|
||||
files.push({
|
||||
id,
|
||||
path: `posts/post-${id}.md`,
|
||||
});
|
||||
}
|
||||
|
||||
it('should handle next action', async () => {
|
||||
const giteaImplementation = new GiteaImplementation(config);
|
||||
giteaImplementation.api = mockAPI;
|
||||
|
||||
const cursor = Cursor.create({
|
||||
actions: ['next', 'last'],
|
||||
meta: { page: 1, count, pageSize: 20, pageCount: 76 },
|
||||
data: { files },
|
||||
});
|
||||
|
||||
const expectedEntries = files
|
||||
.slice(20, 40)
|
||||
.map(({ id, path }) => ({ data: id, file: { path, id } }));
|
||||
|
||||
const expectedCursor = Cursor.create({
|
||||
actions: ['prev', 'first', 'next', 'last'],
|
||||
meta: { page: 2, count, pageSize: 20, pageCount: 76 },
|
||||
data: { files },
|
||||
});
|
||||
|
||||
const result = await giteaImplementation.traverseCursor(cursor, 'next');
|
||||
|
||||
expect(result).toEqual({
|
||||
entries: expectedEntries,
|
||||
cursor: expectedCursor,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle prev action', async () => {
|
||||
const giteaImplementation = new GiteaImplementation(config);
|
||||
giteaImplementation.api = mockAPI;
|
||||
|
||||
const cursor = Cursor.create({
|
||||
actions: ['prev', 'first', 'next', 'last'],
|
||||
meta: { page: 2, count, pageSize: 20, pageCount: 76 },
|
||||
data: { files },
|
||||
});
|
||||
|
||||
const expectedEntries = files
|
||||
.slice(0, 20)
|
||||
.map(({ id, path }) => ({ data: id, file: { path, id } }));
|
||||
|
||||
const expectedCursor = Cursor.create({
|
||||
actions: ['next', 'last'],
|
||||
meta: { page: 1, count, pageSize: 20, pageCount: 76 },
|
||||
data: { files },
|
||||
});
|
||||
|
||||
const result = await giteaImplementation.traverseCursor(cursor, 'prev');
|
||||
|
||||
expect(result).toEqual({
|
||||
entries: expectedEntries,
|
||||
cursor: expectedCursor,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle last action', async () => {
|
||||
const giteaImplementation = new GiteaImplementation(config);
|
||||
giteaImplementation.api = mockAPI;
|
||||
|
||||
const cursor = Cursor.create({
|
||||
actions: ['next', 'last'],
|
||||
meta: { page: 1, count, pageSize: 20, pageCount: 76 },
|
||||
data: { files },
|
||||
});
|
||||
|
||||
const expectedEntries = files
|
||||
.slice(1500)
|
||||
.map(({ id, path }) => ({ data: id, file: { path, id } }));
|
||||
|
||||
const expectedCursor = Cursor.create({
|
||||
actions: ['prev', 'first'],
|
||||
meta: { page: 76, count, pageSize: 20, pageCount: 76 },
|
||||
data: { files },
|
||||
});
|
||||
|
||||
const result = await giteaImplementation.traverseCursor(cursor, 'last');
|
||||
|
||||
expect(result).toEqual({
|
||||
entries: expectedEntries,
|
||||
cursor: expectedCursor,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle first action', async () => {
|
||||
const giteaImplementation = new GiteaImplementation(config);
|
||||
giteaImplementation.api = mockAPI;
|
||||
|
||||
const cursor = Cursor.create({
|
||||
actions: ['prev', 'first'],
|
||||
meta: { page: 76, count, pageSize: 20, pageCount: 76 },
|
||||
data: { files },
|
||||
});
|
||||
|
||||
const expectedEntries = files
|
||||
.slice(0, 20)
|
||||
.map(({ id, path }) => ({ data: id, file: { path, id } }));
|
||||
|
||||
const expectedCursor = Cursor.create({
|
||||
actions: ['next', 'last'],
|
||||
meta: { page: 1, count, pageSize: 20, pageCount: 76 },
|
||||
data: { files },
|
||||
});
|
||||
|
||||
const result = await giteaImplementation.traverseCursor(cursor, 'first');
|
||||
|
||||
expect(result).toEqual({
|
||||
entries: expectedEntries,
|
||||
cursor: expectedCursor,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
405
packages/core/src/backends/gitea/implementation.tsx
Normal file
405
packages/core/src/backends/gitea/implementation.tsx
Normal file
@ -0,0 +1,405 @@
|
||||
import { stripIndent } from 'common-tags';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import semaphore from 'semaphore';
|
||||
|
||||
import {
|
||||
asyncLock,
|
||||
basename,
|
||||
blobToFileObj,
|
||||
Cursor,
|
||||
CURSOR_COMPATIBILITY_SYMBOL,
|
||||
entriesByFiles,
|
||||
entriesByFolder,
|
||||
filterByExtension,
|
||||
getBlobSHA,
|
||||
getMediaAsBlob,
|
||||
getMediaDisplayURL,
|
||||
runWithLock,
|
||||
unsentRequest,
|
||||
} from '@staticcms/core/lib/util';
|
||||
import API, { API_NAME } from './API';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
import type {
|
||||
BackendClass,
|
||||
BackendEntry,
|
||||
Config,
|
||||
Credentials,
|
||||
DisplayURL,
|
||||
ImplementationFile,
|
||||
PersistOptions,
|
||||
User,
|
||||
} from '@staticcms/core/interface';
|
||||
import type { AsyncLock } from '@staticcms/core/lib/util';
|
||||
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
|
||||
import type { Semaphore } from 'semaphore';
|
||||
import type { GiteaUser } from './types';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
type ApiFile = { id: string; type: string; name: string; path: string; size: number };
|
||||
|
||||
const { fetchWithTimeout: fetch } = unsentRequest;
|
||||
|
||||
export default class Gitea implements BackendClass {
|
||||
lock: AsyncLock;
|
||||
api: API | null;
|
||||
options: {
|
||||
proxied: boolean;
|
||||
API: API | null;
|
||||
};
|
||||
originRepo: string;
|
||||
repo?: string;
|
||||
branch: string;
|
||||
apiRoot: string;
|
||||
mediaFolder?: string;
|
||||
token: string | null;
|
||||
_currentUserPromise?: Promise<GiteaUser>;
|
||||
_userIsOriginMaintainerPromises?: {
|
||||
[key: string]: Promise<boolean>;
|
||||
};
|
||||
_mediaDisplayURLSem?: Semaphore;
|
||||
|
||||
constructor(config: Config, options = {}) {
|
||||
this.options = {
|
||||
proxied: false,
|
||||
API: null,
|
||||
...options,
|
||||
};
|
||||
|
||||
if (
|
||||
!this.options.proxied &&
|
||||
(config.backend.repo === null || config.backend.repo === undefined)
|
||||
) {
|
||||
throw new Error('The Gitea backend needs a "repo" in the backend configuration.');
|
||||
}
|
||||
|
||||
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.token = '';
|
||||
this.mediaFolder = config.media_folder;
|
||||
this.lock = asyncLock();
|
||||
}
|
||||
|
||||
isGitBackend() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async status() {
|
||||
const auth =
|
||||
(await this.api
|
||||
?.user()
|
||||
.then(user => !!user)
|
||||
.catch(e => {
|
||||
console.warn('Failed getting Gitea user', e);
|
||||
return false;
|
||||
})) || false;
|
||||
|
||||
return { auth: { status: auth }, api: { status: true, statusPage: '' } };
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
}
|
||||
|
||||
restoreUser(user: User) {
|
||||
return this.authenticate(user);
|
||||
}
|
||||
|
||||
async currentUser({ token }: { token: string }) {
|
||||
if (!this._currentUserPromise) {
|
||||
this._currentUserPromise = fetch(`${this.apiRoot}/user`, {
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
},
|
||||
}).then(res => res.json());
|
||||
}
|
||||
return this._currentUserPromise;
|
||||
}
|
||||
|
||||
async userIsOriginMaintainer({
|
||||
username: usernameArg,
|
||||
token,
|
||||
}: {
|
||||
username?: string;
|
||||
token: string;
|
||||
}) {
|
||||
const username = usernameArg || (await this.currentUser({ token })).login;
|
||||
this._userIsOriginMaintainerPromises = this._userIsOriginMaintainerPromises || {};
|
||||
if (!this._userIsOriginMaintainerPromises[username]) {
|
||||
this._userIsOriginMaintainerPromises[username] = fetch(
|
||||
`${this.apiRoot}/repos/${this.originRepo}/collaborators/${username}/permission`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
.then(res => res.json())
|
||||
.then(({ permission }) => permission === 'admin' || permission === 'write');
|
||||
}
|
||||
return this._userIsOriginMaintainerPromises[username];
|
||||
}
|
||||
|
||||
async authenticate(state: Credentials) {
|
||||
this.token = state.token as string;
|
||||
const apiCtor = API;
|
||||
this.api = new apiCtor({
|
||||
token: this.token,
|
||||
branch: this.branch,
|
||||
repo: this.repo,
|
||||
originRepo: this.originRepo,
|
||||
apiRoot: this.apiRoot,
|
||||
});
|
||||
const user = await this.api!.user();
|
||||
const isCollab = await this.api!.hasWriteAccess().catch(error => {
|
||||
error.message = stripIndent`
|
||||
Repo "${this.repo}" not found.
|
||||
|
||||
Please ensure the repo information is spelled correctly.
|
||||
|
||||
If the repo is private, make sure you're logged into a Gitea account with access.
|
||||
|
||||
If your repo is under an organization, ensure the organization has granted access to Static
|
||||
CMS.
|
||||
`;
|
||||
throw error;
|
||||
});
|
||||
|
||||
// Unauthorized user
|
||||
if (!isCollab) {
|
||||
throw new Error('Your Gitea user account does not have access to this repo.');
|
||||
}
|
||||
|
||||
// Authorized user
|
||||
return { ...user, token: state.token as string };
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.token = null;
|
||||
if (this.api && this.api.reset && typeof this.api.reset === 'function') {
|
||||
return this.api.reset();
|
||||
}
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return Promise.resolve(this.token);
|
||||
}
|
||||
|
||||
getCursorAndFiles = (files: ApiFile[], page: number) => {
|
||||
const pageSize = 20;
|
||||
const count = files.length;
|
||||
const pageCount = Math.ceil(files.length / pageSize);
|
||||
|
||||
const actions = [] as string[];
|
||||
if (page > 1) {
|
||||
actions.push('prev');
|
||||
actions.push('first');
|
||||
}
|
||||
if (page < pageCount) {
|
||||
actions.push('next');
|
||||
actions.push('last');
|
||||
}
|
||||
|
||||
const cursor = Cursor.create({
|
||||
actions,
|
||||
meta: { page, count, pageSize, pageCount },
|
||||
data: { files },
|
||||
});
|
||||
const pageFiles = files.slice((page - 1) * pageSize, page * pageSize);
|
||||
return { cursor, files: pageFiles };
|
||||
};
|
||||
|
||||
async entriesByFolder(folder: string, extension: string, depth: number) {
|
||||
const repoURL = this.api!.originRepoURL;
|
||||
|
||||
let cursor: Cursor;
|
||||
|
||||
const listFiles = () =>
|
||||
this.api!.listFiles(folder, {
|
||||
repoURL,
|
||||
depth,
|
||||
}).then(files => {
|
||||
const filtered = files.filter(file => filterByExtension(file, extension));
|
||||
const result = this.getCursorAndFiles(filtered, 1);
|
||||
cursor = result.cursor;
|
||||
return result.files;
|
||||
});
|
||||
|
||||
const readFile = (path: string, id: string | null | undefined) =>
|
||||
this.api!.readFile(path, id, { repoURL }) as Promise<string>;
|
||||
|
||||
const files = await entriesByFolder(
|
||||
listFiles,
|
||||
readFile,
|
||||
this.api!.readFileMetadata.bind(this.api),
|
||||
API_NAME,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
files[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
|
||||
return files;
|
||||
}
|
||||
|
||||
async allEntriesByFolder(folder: string, extension: string, depth: number) {
|
||||
const repoURL = this.api!.originRepoURL;
|
||||
|
||||
const listFiles = () =>
|
||||
this.api!.listFiles(folder, {
|
||||
repoURL,
|
||||
depth,
|
||||
}).then(files => files.filter(file => filterByExtension(file, extension)));
|
||||
|
||||
const readFile = (path: string, id: string | null | undefined) => {
|
||||
return this.api!.readFile(path, id, { repoURL }) as Promise<string>;
|
||||
};
|
||||
|
||||
const files = await entriesByFolder(
|
||||
listFiles,
|
||||
readFile,
|
||||
this.api!.readFileMetadata.bind(this.api),
|
||||
API_NAME,
|
||||
);
|
||||
return files;
|
||||
}
|
||||
|
||||
entriesByFiles(files: ImplementationFile[]) {
|
||||
const repoURL = this.api!.repoURL;
|
||||
|
||||
const readFile = (path: string, id: string | null | undefined) =>
|
||||
this.api!.readFile(path, id, { repoURL }).catch(() => '') as Promise<string>;
|
||||
|
||||
return entriesByFiles(files, readFile, this.api!.readFileMetadata.bind(this.api), API_NAME);
|
||||
}
|
||||
|
||||
// Fetches a single entry.
|
||||
getEntry(path: string) {
|
||||
const repoURL = this.api!.originRepoURL;
|
||||
return this.api!.readFile(path, null, { repoURL })
|
||||
.then(data => ({
|
||||
file: { path, id: null },
|
||||
data: data as string,
|
||||
}))
|
||||
.catch(() => ({ file: { path, id: null }, data: '' }));
|
||||
}
|
||||
|
||||
async getMedia(mediaFolder = this.mediaFolder) {
|
||||
if (!mediaFolder) {
|
||||
return [];
|
||||
}
|
||||
return this.api!.listFiles(mediaFolder).then(files =>
|
||||
files.map(({ id, name, size, path }) => {
|
||||
// load media using getMediaDisplayURL to avoid token expiration with Gitlab raw content urls
|
||||
// for private repositories
|
||||
return { id, name, size, displayURL: { id, path }, path };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getMediaFile(path: string) {
|
||||
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
|
||||
|
||||
const name = basename(path);
|
||||
const fileObj = blobToFileObj(name, blob);
|
||||
const url = URL.createObjectURL(fileObj);
|
||||
const id = await getBlobSHA(blob);
|
||||
|
||||
return {
|
||||
id,
|
||||
displayURL: url,
|
||||
path,
|
||||
name,
|
||||
size: fileObj.size,
|
||||
file: fileObj,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
getMediaDisplayURL(displayURL: DisplayURL) {
|
||||
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
return getMediaDisplayURL(
|
||||
displayURL,
|
||||
this.api!.readFile.bind(this.api!),
|
||||
this._mediaDisplayURLSem,
|
||||
);
|
||||
}
|
||||
|
||||
persistEntry(entry: BackendEntry, options: PersistOptions) {
|
||||
// persistEntry is a transactional operation
|
||||
return runWithLock(
|
||||
this.lock,
|
||||
() => this.api!.persistFiles(entry.dataFiles, entry.assets, options),
|
||||
'Failed to acquire persist entry lock',
|
||||
);
|
||||
}
|
||||
|
||||
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
|
||||
try {
|
||||
await this.api!.persistFiles([], [mediaFile], options);
|
||||
const { sha, path, fileObj } = mediaFile as AssetProxy & { sha: string };
|
||||
const displayURL = URL.createObjectURL(fileObj as Blob);
|
||||
return {
|
||||
id: sha,
|
||||
name: fileObj!.name,
|
||||
size: fileObj!.size,
|
||||
displayURL,
|
||||
path: trimStart(path, '/'),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
deleteFiles(paths: string[], commitMessage: string) {
|
||||
return this.api!.deleteFiles(paths, commitMessage);
|
||||
}
|
||||
|
||||
async traverseCursor(cursor: Cursor, action: string) {
|
||||
const meta = cursor.meta;
|
||||
const files = (cursor.data?.files ?? []) as ApiFile[];
|
||||
|
||||
let result: { cursor: Cursor; files: ApiFile[] };
|
||||
switch (action) {
|
||||
case 'first': {
|
||||
result = this.getCursorAndFiles(files, 1);
|
||||
break;
|
||||
}
|
||||
case 'last': {
|
||||
result = this.getCursorAndFiles(files, (meta?.['pageCount'] as number) ?? 1);
|
||||
break;
|
||||
}
|
||||
case 'next': {
|
||||
result = this.getCursorAndFiles(files, (meta?.['page'] as number) + 1 ?? 1);
|
||||
break;
|
||||
}
|
||||
case 'prev': {
|
||||
result = this.getCursorAndFiles(files, (meta?.['page'] as number) - 1 ?? 1);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
result = this.getCursorAndFiles(files, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const readFile = (path: string, id: string | null | undefined) =>
|
||||
this.api!.readFile(path, id, { repoURL: this.api!.originRepoURL }).catch(
|
||||
() => '',
|
||||
) as Promise<string>;
|
||||
|
||||
const entries = await entriesByFiles(
|
||||
result.files,
|
||||
readFile,
|
||||
this.api!.readFileMetadata.bind(this.api),
|
||||
API_NAME,
|
||||
);
|
||||
|
||||
return {
|
||||
entries,
|
||||
cursor: result.cursor,
|
||||
};
|
||||
}
|
||||
}
|
3
packages/core/src/backends/gitea/index.ts
Normal file
3
packages/core/src/backends/gitea/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as GiteaBackend } from './implementation';
|
||||
export { default as API } from './API';
|
||||
export { default as AuthenticationPage } from './AuthenticationPage';
|
247
packages/core/src/backends/gitea/types.ts
Normal file
247
packages/core/src/backends/gitea/types.ts
Normal file
@ -0,0 +1,247 @@
|
||||
export type GiteaUser = {
|
||||
active: boolean;
|
||||
avatar_url: string;
|
||||
created: string;
|
||||
description: string;
|
||||
email: string;
|
||||
followers_count: number;
|
||||
following_count: number;
|
||||
full_name: string;
|
||||
id: number;
|
||||
is_admin: boolean;
|
||||
language: string;
|
||||
last_login: string;
|
||||
location: string;
|
||||
login: string;
|
||||
login_name?: string;
|
||||
prohibit_login: boolean;
|
||||
restricted: boolean;
|
||||
starred_repos_count: number;
|
||||
visibility: string;
|
||||
website: string;
|
||||
};
|
||||
|
||||
export type GiteaTeam = {
|
||||
can_create_org_repo: boolean;
|
||||
description: string;
|
||||
id: number;
|
||||
includes_all_repositories: boolean;
|
||||
name: string;
|
||||
organization: GiteaOrganization;
|
||||
permission: string;
|
||||
units: Array<string>;
|
||||
units_map: Map<string, string>;
|
||||
};
|
||||
|
||||
export type GiteaOrganization = {
|
||||
avatar_url: string;
|
||||
description: string;
|
||||
full_name: string;
|
||||
id: number;
|
||||
location: string;
|
||||
name: string;
|
||||
repo_admin_change_team_access: boolean;
|
||||
username: string;
|
||||
visibility: string;
|
||||
website: string;
|
||||
};
|
||||
|
||||
type ReposListCommitsResponseItemCommitUser = {
|
||||
date: string;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ReposListCommitsResponseItemCommitMeta = {
|
||||
created: string;
|
||||
sha: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type PayloadUser = {
|
||||
email: string;
|
||||
name: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
type PayloadCommitVerification = {
|
||||
payload: string;
|
||||
reason: string;
|
||||
signature: string;
|
||||
signer: PayloadUser;
|
||||
verified: boolean;
|
||||
};
|
||||
|
||||
type ReposListCommitsResponseItemCommit = {
|
||||
author: ReposListCommitsResponseItemCommitUser;
|
||||
committer: ReposListCommitsResponseItemCommitUser;
|
||||
message: string;
|
||||
tree: ReposListCommitsResponseItemCommitMeta;
|
||||
url: string;
|
||||
verification: PayloadCommitVerification;
|
||||
};
|
||||
|
||||
type ReposGetResponsePermissions = {
|
||||
admin: boolean;
|
||||
pull: boolean;
|
||||
push: boolean;
|
||||
};
|
||||
|
||||
type ReposGetResponseExternalTracker = {
|
||||
external_tracker_format: string;
|
||||
external_tracker_regexp_pattern: string;
|
||||
external_tracker_style: string;
|
||||
external_tracker_url: string;
|
||||
};
|
||||
|
||||
type ReposGetResponseExternalWiki = {
|
||||
external_wiki_url: string;
|
||||
};
|
||||
|
||||
type ReposGetResponseInternalTracker = {
|
||||
allow_only_contributors_to_track_time: boolean;
|
||||
enable_issue_dependencies: boolean;
|
||||
enable_time_tracker: boolean;
|
||||
};
|
||||
|
||||
type ReposGetResponseRepoTransfer = {
|
||||
description: string;
|
||||
doer: GiteaUser;
|
||||
recipient: GiteaUser;
|
||||
teams: Array<GiteaTeam>;
|
||||
enable_issue_dependencies: boolean;
|
||||
enable_time_tracker: boolean;
|
||||
};
|
||||
|
||||
export type ReposGetResponse = {
|
||||
allow_merge_commits: boolean;
|
||||
allow_rebase: boolean;
|
||||
allow_rebase_explicit: boolean;
|
||||
allow_rebase_update: boolean;
|
||||
allow_squash_merge: boolean;
|
||||
archived: boolean;
|
||||
avatar_url: string;
|
||||
clone_url: string;
|
||||
created_at: string;
|
||||
default_branch: string;
|
||||
default_delete_branch_after_merge: boolean;
|
||||
default_merge_style: boolean;
|
||||
description: string;
|
||||
empty: boolean;
|
||||
external_tracker: ReposGetResponseExternalTracker;
|
||||
external_wiki: ReposGetResponseExternalWiki;
|
||||
fork: boolean;
|
||||
forks_count: number;
|
||||
full_name: string;
|
||||
has_issues: boolean;
|
||||
has_projects: boolean;
|
||||
has_pull_requests: boolean;
|
||||
has_wiki: boolean;
|
||||
html_url: string;
|
||||
id: number;
|
||||
ignore_whitespace_conflicts: boolean;
|
||||
internal: boolean;
|
||||
internal_tracker: ReposGetResponseInternalTracker;
|
||||
language: string;
|
||||
languages_url: string;
|
||||
mirror: boolean;
|
||||
mirror_interval: string;
|
||||
mirror_updated: string;
|
||||
name: string;
|
||||
open_issues_count: number;
|
||||
open_pr_counter: number;
|
||||
original_url: string;
|
||||
owner: GiteaUser;
|
||||
parent: null;
|
||||
permissions: ReposGetResponsePermissions;
|
||||
private: boolean;
|
||||
release_counter: number;
|
||||
repo_transfer: ReposGetResponseRepoTransfer;
|
||||
size: number;
|
||||
ssh_url: string;
|
||||
stars_count: number;
|
||||
template: boolean;
|
||||
updated_at: string;
|
||||
watchers_count: number;
|
||||
website: string;
|
||||
};
|
||||
|
||||
type ReposListCommitsResponseItemCommitAffectedFiles = {
|
||||
filename: string;
|
||||
};
|
||||
|
||||
type ReposListCommitsResponseItemCommitStats = {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
type ReposListCommitsResponseItem = {
|
||||
author: GiteaUser;
|
||||
commit: ReposListCommitsResponseItemCommit;
|
||||
committer: GiteaUser;
|
||||
created: string;
|
||||
files: Array<ReposListCommitsResponseItemCommitAffectedFiles>;
|
||||
html_url: string;
|
||||
parents: Array<ReposListCommitsResponseItemCommitMeta>;
|
||||
sha: string;
|
||||
stats: ReposListCommitsResponseItemCommitStats;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ReposListCommitsResponse = Array<ReposListCommitsResponseItem>;
|
||||
|
||||
export type GitGetBlobResponse = {
|
||||
content: string;
|
||||
encoding: string;
|
||||
sha: string;
|
||||
size: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type GitGetTreeResponseTreeItem = {
|
||||
mode: string;
|
||||
path: string;
|
||||
sha: string;
|
||||
size?: number;
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type GitGetTreeResponse = {
|
||||
page: number;
|
||||
sha: string;
|
||||
total_count: number;
|
||||
tree: Array<GitGetTreeResponseTreeItem>;
|
||||
truncated: boolean;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type GiteaIdentity = {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type FileLinksResponse = {
|
||||
git: string;
|
||||
html: string;
|
||||
self: string;
|
||||
};
|
||||
|
||||
export type ContentsResponse = {
|
||||
_links: FileLinksResponse;
|
||||
content?: string | null;
|
||||
download_url: string;
|
||||
encoding?: string | null;
|
||||
git_url: string;
|
||||
html_url: string;
|
||||
last_commit_sha: string;
|
||||
name: string;
|
||||
path: string;
|
||||
sha: string;
|
||||
size: number;
|
||||
submodule_git_url?: string | null;
|
||||
target?: string | null;
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
@ -2,5 +2,6 @@ export { BitbucketBackend } from './bitbucket';
|
||||
export { GitGatewayBackend } from './git-gateway';
|
||||
export { GitHubBackend } from './github';
|
||||
export { GitLabBackend } from './gitlab';
|
||||
export { GiteaBackend } from './gitea';
|
||||
export { ProxyBackend } from './proxy';
|
||||
export { TestBackend } from './test';
|
||||
|
@ -237,7 +237,7 @@ const MediaLibrary = ({
|
||||
if (
|
||||
!(await confirm({
|
||||
title: 'mediaLibrary.mediaLibrary.onDeleteTitle',
|
||||
body: 'mediaLibrary.mediaLibrary.onDelete',
|
||||
body: 'mediaLibrary.mediaLibrary.onDeleteBody',
|
||||
color: 'error',
|
||||
}))
|
||||
) {
|
||||
|
@ -1,12 +1,14 @@
|
||||
import bitbucket from './bitbucket.svg';
|
||||
import github from './github.svg';
|
||||
import gitlab from './gitlab.svg';
|
||||
import gitea from './gitea.svg';
|
||||
import staticCms from './static-cms-logo.svg';
|
||||
|
||||
const images = {
|
||||
bitbucket,
|
||||
github,
|
||||
gitlab,
|
||||
gitea,
|
||||
'static-cms': staticCms,
|
||||
};
|
||||
|
||||
|
12
packages/core/src/components/UI/Icon/images/gitea.svg
Normal file
12
packages/core/src/components/UI/Icon/images/gitea.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg version="1.1" id="main_outline" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 640 640" style="enable-background:new 0 0 640 640;" xml:space="preserve">
|
||||
<g>
|
||||
<path id="teabag" style="fill:#FFFFFF" d="M395.9,484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5,21.2-17.9,33.8-11.8 c17.2,8.3,27.1,13,27.1,13l-0.1-109.2l16.7-0.1l0.1,117.1c0,0,57.4,24.2,83.1,40.1c3.7,2.3,10.2,6.8,12.9,14.4 c2.1,6.1,2,13.1-1,19.3l-61,126.9C423.6,484.9,408.4,490.3,395.9,484.2z"/>
|
||||
<g>
|
||||
<g>
|
||||
<path style="fill:#609926" d="M622.7,149.8c-4.1-4.1-9.6-4-9.6-4s-117.2,6.6-177.9,8c-13.3,0.3-26.5,0.6-39.6,0.7c0,39.1,0,78.2,0,117.2 c-5.5-2.6-11.1-5.3-16.6-7.9c0-36.4-0.1-109.2-0.1-109.2c-29,0.4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5 c-9.8-0.6-22.5-2.1-39,1.5c-8.7,1.8-33.5,7.4-53.8,26.9C-4.9,212.4,6.6,276.2,8,285.8c1.7,11.7,6.9,44.2,31.7,72.5 c45.8,56.1,144.4,54.8,144.4,54.8s12.1,28.9,30.6,55.5c25,33.1,50.7,58.9,75.7,62c63,0,188.9-0.1,188.9-0.1s12,0.1,28.3-10.3 c14-8.5,26.5-23.4,26.5-23.4s12.9-13.8,30.9-45.3c5.5-9.7,10.1-19.1,14.1-28c0,0,55.2-117.1,55.2-231.1 C633.2,157.9,624.7,151.8,622.7,149.8z M125.6,353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6,321.8,60,295.4 c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5,38.5-30c13.8-3.7,31-3.1,31-3.1s7.1,59.4,15.7,94.2c7.2,29.2,24.8,77.7,24.8,77.7 S142.5,359.9,125.6,353.9z M425.9,461.5c0,0-6.1,14.5-19.6,15.4c-5.8,0.4-10.3-1.2-10.3-1.2s-0.3-0.1-5.3-2.1l-112.9-55 c0,0-10.9-5.7-12.8-15.6c-2.2-8.1,2.7-18.1,2.7-18.1L322,273c0,0,4.8-9.7,12.2-13c0.6-0.3,2.3-1,4.5-1.5c8.1-2.1,18,2.8,18,2.8 l110.7,53.7c0,0,12.6,5.7,15.3,16.2c1.9,7.4-0.5,14-1.8,17.2C474.6,363.8,425.9,461.5,425.9,461.5z"/>
|
||||
<path style="fill:#609926" d="M326.8,380.1c-8.2,0.1-15.4,5.8-17.3,13.8c-1.9,8,2,16.3,9.1,20c7.7,4,17.5,1.8,22.7-5.4 c5.1-7.1,4.3-16.9-1.8-23.1l24-49.1c1.5,0.1,3.7,0.2,6.2-0.5c4.1-0.9,7.1-3.6,7.1-3.6c4.2,1.8,8.6,3.8,13.2,6.1 c4.8,2.4,9.3,4.9,13.4,7.3c0.9,0.5,1.8,1.1,2.8,1.9c1.6,1.3,3.4,3.1,4.7,5.5c1.9,5.5-1.9,14.9-1.9,14.9 c-2.3,7.6-18.4,40.6-18.4,40.6c-8.1-0.2-15.3,5-17.7,12.5c-2.6,8.1,1.1,17.3,8.9,21.3c7.8,4,17.4,1.7,22.5-5.3 c5-6.8,4.6-16.3-1.1-22.6c1.9-3.7,3.7-7.4,5.6-11.3c5-10.4,13.5-30.4,13.5-30.4c0.9-1.7,5.7-10.3,2.7-21.3 c-2.5-11.4-12.6-16.7-12.6-16.7c-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3c4.7-9.7,9.4-19.3,14.1-29 c-4.1-2-8.1-4-12.2-6.1c-4.8,9.8-9.7,19.7-14.5,29.5c-6.7-0.1-12.9,3.5-16.1,9.4c-3.4,6.3-2.7,14.1,1.9,19.8 C343.2,346.5,335,363.3,326.8,380.1z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.5 KiB |
@ -3,6 +3,7 @@ import {
|
||||
GitGatewayBackend,
|
||||
GitHubBackend,
|
||||
GitLabBackend,
|
||||
GiteaBackend,
|
||||
ProxyBackend,
|
||||
TestBackend,
|
||||
} from './backends';
|
||||
@ -32,6 +33,7 @@ export default function addExtensions() {
|
||||
registerBackend('git-gateway', GitGatewayBackend);
|
||||
registerBackend('github', GitHubBackend);
|
||||
registerBackend('gitlab', GitLabBackend);
|
||||
registerBackend('gitea', GiteaBackend);
|
||||
registerBackend('bitbucket', BitbucketBackend);
|
||||
registerBackend('test-repo', TestBackend);
|
||||
registerBackend('proxy', ProxyBackend);
|
||||
|
@ -27,6 +27,10 @@ const PROVIDERS = {
|
||||
width: 960,
|
||||
height: 600,
|
||||
},
|
||||
gitea: {
|
||||
width: 960,
|
||||
height: 600,
|
||||
},
|
||||
bitbucket: {
|
||||
width: 960,
|
||||
height: 500,
|
||||
|
@ -8,6 +8,7 @@ const bg: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Вход с Bitbucket',
|
||||
loginWithGitHub: 'Вход с GitHub',
|
||||
loginWithGitLab: 'Вход с GitLab',
|
||||
loginWithGitea: 'Вход с Gitea',
|
||||
errors: {
|
||||
email: 'Въведете вашия имейл.',
|
||||
password: 'Въведете паролата.',
|
||||
|
@ -8,6 +8,7 @@ const ca: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Iniciar sessió amb Bitbucket',
|
||||
loginWithGitHub: 'Iniciar sessió amb GitHub',
|
||||
loginWithGitLab: 'Iniciar sessió amb GitLab',
|
||||
loginWithGitea: 'Iniciar sessió amb Gitea',
|
||||
errors: {
|
||||
email: 'Comprova que has escrit el teu email.',
|
||||
password: 'Si us plau escriu la teva contrasenya.',
|
||||
|
@ -8,6 +8,7 @@ const cs: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Přihlásit pomocí Bitbucket',
|
||||
loginWithGitHub: 'Přihlásit pomocí GitHub',
|
||||
loginWithGitLab: 'Přihlásit pomocí GitLab',
|
||||
loginWithGitea: 'Přihlásit pomocí Gitea',
|
||||
errors: {
|
||||
email: 'Vyplňte e-mailovou adresu.',
|
||||
password: 'Vyplňte heslo.',
|
||||
|
@ -8,6 +8,7 @@ const da: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Log ind med Bitbucket',
|
||||
loginWithGitHub: 'Log ind med GitHub',
|
||||
loginWithGitLab: 'Log ind med GitLab',
|
||||
loginWithGitea: 'Log ind med Gitea',
|
||||
errors: {
|
||||
email: 'Vær sikker på du har indtastet din e-mail.',
|
||||
password: 'Indtast dit kodeord.',
|
||||
|
@ -8,6 +8,7 @@ const de: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Mit Bitbucket einloggen',
|
||||
loginWithGitHub: 'Mit GitHub einloggen',
|
||||
loginWithGitLab: 'Mit GitLab einloggen',
|
||||
loginWithGitea: 'Mit Gitea einloggen',
|
||||
errors: {
|
||||
email: 'Stellen Sie sicher, Ihre E-Mail-Adresse einzugeben.',
|
||||
password: 'Bitte geben Sie Ihr Passwort ein.',
|
||||
@ -107,8 +108,10 @@ const de: LocalePhrasesRoot = {
|
||||
onPublishingWithUnsavedChangesBody:
|
||||
'Es sind noch ungespeicherte Änderungen vorhanden. Bitte speicheren Sie vor dem Veröffentlichen.',
|
||||
onPublishingBody: 'Soll dieser Beitrag wirklich veröffentlicht werden?',
|
||||
onDeleteWithUnsavedChangesTitle: 'Veröffentlichten Beitrag löschen?',
|
||||
onDeleteWithUnsavedChangesBody:
|
||||
'Möchten Sie diesen veröffentlichten Beitrag, sowie Ihre nicht gespeicherten Änderungen löschen?',
|
||||
onDeletePublishedEntryTitle: 'Veröffentlichten Beitrag löschen?',
|
||||
onDeletePublishedEntryBody: 'Soll dieser veröffentlichte Beitrag wirklich gelöscht werden?',
|
||||
loadingEntry: 'Beitrag laden...',
|
||||
confirmLoadBackupBody:
|
||||
|
@ -8,6 +8,7 @@ const en: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Login with Bitbucket',
|
||||
loginWithGitHub: 'Login with GitHub',
|
||||
loginWithGitLab: 'Login with GitLab',
|
||||
loginWithGitea: 'Login with Gitea',
|
||||
errors: {
|
||||
email: 'Make sure to enter your email.',
|
||||
password: 'Please enter your password.',
|
||||
|
@ -8,6 +8,7 @@ const es: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Iniciar sesión con Bitbucket',
|
||||
loginWithGitHub: 'Iniciar sesión con GitHub',
|
||||
loginWithGitLab: 'Iniciar sesión con GitLab',
|
||||
loginWithGitea: 'Iniciar sesión con Gitea',
|
||||
errors: {
|
||||
email: 'Asegúrate de introducir tu correo electrónico.',
|
||||
password: 'Por favor introduce tu contraseña.',
|
||||
|
@ -8,6 +8,7 @@ const fr: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Se connecter avec Bitbucket',
|
||||
loginWithGitHub: 'Se connecter avec GitHub',
|
||||
loginWithGitLab: 'Se connecter avec GitLab',
|
||||
loginWithGitea: 'Se connecter avec Gitea',
|
||||
errors: {
|
||||
email: "Assurez-vous d'avoir entré votre email.",
|
||||
password: 'Merci de saisir votre mot de passe.',
|
||||
|
@ -8,6 +8,7 @@ const gr: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Σύνδεση μέσω Bitbucket',
|
||||
loginWithGitHub: 'Σύνδεση μέσω GitHub',
|
||||
loginWithGitLab: 'Σύνδεση μέσω GitLab',
|
||||
loginWithGitea: 'Σύνδεση μέσω Gitea',
|
||||
errors: {
|
||||
email: 'Βεβαιωθείτε ότι έχετε εισαγάγει το email σας.',
|
||||
password: 'Παρακαλώ εισάγετε τον κωδικό πρόσβασής σας.',
|
||||
|
@ -8,6 +8,7 @@ const he: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'התחברות עם Bitbucket',
|
||||
loginWithGitHub: 'התחברות עם GitHub',
|
||||
loginWithGitLab: 'התחברות עם GitLab',
|
||||
loginWithGitea: 'התחברות עם Gitea',
|
||||
errors: {
|
||||
email: 'נא לא לשכוח להקליד את כתובת המייל',
|
||||
password: 'נא להקליד את הסיסמה.',
|
||||
|
@ -8,6 +8,7 @@ const hr: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Prijava sa Bitbucket računom',
|
||||
loginWithGitHub: 'Prijava sa GitHub računom',
|
||||
loginWithGitLab: 'Prijava sa GitLab računom',
|
||||
loginWithGitea: 'Prijava sa Gitea računom',
|
||||
errors: {
|
||||
email: 'Unesite email.',
|
||||
password: 'Molimo unisite lozinku.',
|
||||
|
@ -8,6 +8,7 @@ const it: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Accedi con Bitbucket',
|
||||
loginWithGitHub: 'Accedi con GitHub',
|
||||
loginWithGitLab: 'Accedi con GitLab',
|
||||
loginWithGitea: 'Accedi con Gitea',
|
||||
errors: {
|
||||
email: 'Assicurati di inserire la tua mail.',
|
||||
password: 'Inserisci la tua password.',
|
||||
|
@ -8,6 +8,7 @@ const ja: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Bitbucket でログインする',
|
||||
loginWithGitHub: 'GitHub でログインする',
|
||||
loginWithGitLab: 'GitLab でログインする',
|
||||
loginWithGitea: 'Gitea でログインする',
|
||||
errors: {
|
||||
email: 'メールアドレスを確認してください。',
|
||||
password: 'パスワードを入力してください。',
|
||||
|
@ -8,6 +8,7 @@ const ko: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Bitbucket 으로 로그인',
|
||||
loginWithGitHub: 'GitHub 로 로그인',
|
||||
loginWithGitLab: 'GitLab 으로 로그인',
|
||||
loginWithGitea: 'Gitea 으로 로그인',
|
||||
errors: {
|
||||
email: '반드시 이메일을 입력해 주세요.',
|
||||
password: '암호를 입력해 주세요.',
|
||||
|
@ -8,6 +8,7 @@ const lt: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Prisijungti su Bitbucket',
|
||||
loginWithGitHub: 'Prisijungti su GitHub',
|
||||
loginWithGitLab: 'Prisijungti su GitLab',
|
||||
loginWithGitea: 'Prisijungti su Gitea',
|
||||
errors: {
|
||||
email: 'Įveskite savo elektroninį paštą.',
|
||||
password: 'Įveskite savo slaptažodį.',
|
||||
|
@ -8,6 +8,7 @@ const nb_no: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Logg på med Bitbucket',
|
||||
loginWithGitHub: 'Logg på med GitHub',
|
||||
loginWithGitLab: 'Logg på med GitLab',
|
||||
loginWithGitea: 'Logg på med Gitea',
|
||||
errors: {
|
||||
email: 'Du må skrive inn e-posten din.',
|
||||
password: 'Du må skrive inn passordet ditt.',
|
||||
|
@ -8,6 +8,7 @@ const nl: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Inloggen met Bitbucket',
|
||||
loginWithGitHub: 'Inloggen met GitHub',
|
||||
loginWithGitLab: 'Inloggen met GitLab',
|
||||
loginWithGitea: 'Inloggen met Gitea',
|
||||
errors: {
|
||||
email: 'Voer uw email in.',
|
||||
password: 'Voer uw wachtwoord in.',
|
||||
|
@ -8,6 +8,7 @@ const nn_no: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Logg på med Bitbucket',
|
||||
loginWithGitHub: 'Logg på med GitHub',
|
||||
loginWithGitLab: 'Logg på med GitLab',
|
||||
loginWithGitea: 'Logg på med Gitea',
|
||||
errors: {
|
||||
email: 'Du må skriva inn e-posten din.',
|
||||
password: 'Du må skriva inn passordet ditt.',
|
||||
|
@ -8,6 +8,7 @@ const pl: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Zaloguj przez Bitbucket',
|
||||
loginWithGitHub: 'Zaloguj przez GitHub',
|
||||
loginWithGitLab: 'Zaloguj przez GitLab',
|
||||
loginWithGitea: 'Zaloguj przez Gitea',
|
||||
errors: {
|
||||
email: 'Wprowadź swój adres email',
|
||||
password: 'Wprowadź swoje hasło',
|
||||
|
@ -8,6 +8,7 @@ const pt: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Entrar com o Bitbucket',
|
||||
loginWithGitHub: 'Entrar com o GitHub',
|
||||
loginWithGitLab: 'Entrar com o GitLab',
|
||||
loginWithGitea: 'Entrar com o Gitea',
|
||||
errors: {
|
||||
email: 'Certifique-se de inserir seu e-mail.',
|
||||
password: 'Por favor, insira sua senha.',
|
||||
|
@ -8,6 +8,7 @@ const ro: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Autentifică-te cu Bitbucket',
|
||||
loginWithGitHub: 'Autentifică-te cu GitHub',
|
||||
loginWithGitLab: 'Autentifică-te cu GitLab',
|
||||
loginWithGitea: 'Autentifică-te cu Gitea',
|
||||
errors: {
|
||||
email: 'Asigură-te că ai introdus email-ul.',
|
||||
password: 'Te rugăm introdu parola.',
|
||||
|
@ -8,6 +8,7 @@ const ru: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Войти через Bitbucket',
|
||||
loginWithGitHub: 'Войти через GitHub',
|
||||
loginWithGitLab: 'Войти через GitLab',
|
||||
loginWithGitea: 'Войти через Gitea',
|
||||
errors: {
|
||||
email: 'Введите ваш email.',
|
||||
password: 'Введите пароль.',
|
||||
|
@ -8,6 +8,7 @@ const sv: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Logga in med Bitbucket',
|
||||
loginWithGitHub: 'Logga in med GitHub',
|
||||
loginWithGitLab: 'Logga in med GitLab',
|
||||
loginWithGitea: 'Logga in med Gitea',
|
||||
errors: {
|
||||
email: 'Fyll i din epostadress.',
|
||||
password: 'Vänligen skriv ditt lösenord.',
|
||||
|
@ -8,6 +8,7 @@ const th: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'เข้าสู่ระบบด้วย Bitbucket',
|
||||
loginWithGitHub: 'เข้าสู่ระบบด้วย GitHub',
|
||||
loginWithGitLab: 'เข้าสู่ระบบด้วย GitLab',
|
||||
loginWithGitea: 'เข้าสู่ระบบด้วย Gitea',
|
||||
errors: {
|
||||
email: 'ตรวจสอบให้แน่ใจว่าได้ใส่อีเมลล์แล้ว',
|
||||
password: 'โปรดใส่รหัสผ่านของคุณ',
|
||||
|
@ -8,6 +8,7 @@ const tr: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Bitbucket ile Giriş',
|
||||
loginWithGitHub: 'GitHub ile Giriş',
|
||||
loginWithGitLab: 'GitLab ile Giriş',
|
||||
loginWithGitea: 'Gitea ile Giriş',
|
||||
errors: {
|
||||
email: 'E-postanızı girdiğinizden emin olun.',
|
||||
password: 'Lütfen şifrenizi girin.',
|
||||
|
@ -8,6 +8,7 @@ const vi: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: 'Đăng nhập bằng Bitbucket',
|
||||
loginWithGitHub: 'Đăng nhập bằng GitHub',
|
||||
loginWithGitLab: 'Đăng nhập bằng GitLab',
|
||||
loginWithGitea: 'Đăng nhập bằng Gitea',
|
||||
errors: {
|
||||
email: 'Hãy nhập email của bạn.',
|
||||
password: 'Hãy nhập mật khẩu của bạn.',
|
||||
|
@ -8,6 +8,7 @@ const zh_Hans: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: '使用 Bitbucket 登录',
|
||||
loginWithGitHub: '使用 GitHub 登录',
|
||||
loginWithGitLab: '使用 GitLab 登录',
|
||||
loginWithGitea: '使用 Gitea 登录',
|
||||
errors: {
|
||||
email: '请输入电子邮箱',
|
||||
password: '请输入密码',
|
||||
|
@ -8,6 +8,7 @@ const zh_Hant: LocalePhrasesRoot = {
|
||||
loginWithBitbucket: '使用你的 Bitbucket 帳號來進行登入',
|
||||
loginWithGitHub: '使用你的 GitHub 帳號來進行登入',
|
||||
loginWithGitLab: '使用你的 GitLab 帳號來進行登入',
|
||||
loginWithGitea: '使用你的 Gitea 帳號來進行登入',
|
||||
errors: {
|
||||
email: '請確認你已經輸入你的電子郵件。',
|
||||
password: '請輸入你的密碼。',
|
||||
|
@ -10,15 +10,15 @@ A backend is JavaScript code that allows Static CMS to communicate with a servic
|
||||
|
||||
Individual backends provide their own configuration documentation, but there are some configuration options that are common to multiple backends. A full reference is below. Note that these are properties of the `backend` field, and should be nested under that field.
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| name | 'git-gateway'<br />\| 'github'<br />\| 'gitlab'<br />\| 'bitbucket'<br />\| 'test-repo'<br />\| 'proxy' | | The backend git provider |
|
||||
| repo | string | | Required for `github`, `gitlab`, and `bitbucket` backends. Ignored by `git-gateway`. Follows the pattern `[org-or-username]/[repo-name]` |
|
||||
| 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` | _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 |
|
||||
| auth_endpoint | string | GitHub or Bitbucket<br />`auth`<br /><br />GitLab<br />`oauth/authorize` | _Optional_. Path to append to `base_url` for authentication requests. |
|
||||
| Name | Type | Default | Description |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| name | 'git-gateway'<br />\| 'github'<br />\| 'gitlab'<br />\| 'bitbucket'<br />\| 'gitea'<br />\|'test-repo'<br />\| 'proxy' | | The backend git provider |
|
||||
| repo | string | | Required for `github`, `gitlab`, `gitea` and `bitbucket` backends. Ignored by `git-gateway`. Follows the pattern `[org-or-username]/[repo-name]` |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
|
48
packages/docs/content/docs/gitea-backend.mdx
Normal file
48
packages/docs/content/docs/gitea-backend.mdx
Normal file
@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Gitea
|
||||
group: Backends
|
||||
weight: 45
|
||||
beta: true
|
||||
---
|
||||
|
||||
- **Name**: `gitea`
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
To enable basic Gitea authentication:
|
||||
|
||||
1. Setup an own OAuth provider, for example with [scm-oauth](https://github.com/denyskon/scm-oauth-provider).
|
||||
2. Add the following lines to your Static CMS `config` file:
|
||||
|
||||
<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
|
||||
# optional, defaults to main
|
||||
# branch: main
|
||||
|
||||
```
|
||||
|
||||
```js
|
||||
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
|
||||
// optional, defaults to main
|
||||
// branch: 'main'
|
||||
},
|
||||
```
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
## Git Large File Storage (LFS)
|
||||
|
||||
Please note that the Gitea backend **does not** support [git-lfs](https://git-lfs.github.com/).
|
Loading…
x
Reference in New Issue
Block a user