diff --git a/dev-test/backends/gitea/config.yml b/dev-test/backends/gitea/config.yml
new file mode 100644
index 000000000000..483c154e15bb
--- /dev/null
+++ b/dev-test/backends/gitea/config.yml
@@ -0,0 +1,63 @@
+backend:
+ name: gitea
+ app_id: a582de8c-2459-4e5f-b671-80f99a0592cc
+ branch: master
+ repo: owner/repo
+
+media_folder: static/media
+public_folder: /media
+collections:
+ - name: posts
+ label: Posts
+ label_singular: 'Post'
+ folder: content/posts
+ create: true
+ slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
+ fields:
+ - label: Template
+ name: template
+ widget: hidden
+ default: post
+ - label: Title
+ name: title
+ widget: string
+ - label: 'Cover Image'
+ name: 'image'
+ widget: 'image'
+ required: false
+ - label: Publish Date
+ name: date
+ widget: datetime
+ - label: Description
+ name: description
+ widget: text
+ - label: Category
+ name: category
+ widget: string
+ - label: Body
+ name: body
+ widget: markdown
+ - label: Tags
+ name: tags
+ widget: list
+ - name: pages
+ label: Pages
+ label_singular: 'Page'
+ folder: content/pages
+ create: true
+ slug: '{{slug}}'
+ fields:
+ - label: Template
+ name: template
+ widget: hidden
+ default: page
+ - label: Title
+ name: title
+ widget: string
+ - label: Draft
+ name: draft
+ widget: boolean
+ default: true
+ - label: Body
+ name: body
+ widget: markdown
diff --git a/dev-test/backends/gitea/index.html b/dev-test/backends/gitea/index.html
new file mode 100644
index 000000000000..dc20859bd218
--- /dev/null
+++ b/dev-test/backends/gitea/index.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+ Decap CMS Development Test
+
+
+
+
+
+
diff --git a/packages/decap-cms-app/src/extensions.js b/packages/decap-cms-app/src/extensions.js
index bed24c1e2e89..92fc0bbcbcbc 100644
--- a/packages/decap-cms-app/src/extensions.js
+++ b/packages/decap-cms-app/src/extensions.js
@@ -4,6 +4,7 @@ import { DecapCmsCore as CMS } from 'decap-cms-core';
import { AzureBackend } from 'decap-cms-backend-azure';
import { GitHubBackend } from 'decap-cms-backend-github';
import { GitLabBackend } from 'decap-cms-backend-gitlab';
+import { GiteaBackend } from 'decap-cms-backend-gitea';
import { GitGatewayBackend } from 'decap-cms-backend-git-gateway';
import { BitbucketBackend } from 'decap-cms-backend-bitbucket';
import { TestBackend } from 'decap-cms-backend-test';
@@ -34,6 +35,7 @@ CMS.registerBackend('git-gateway', GitGatewayBackend);
CMS.registerBackend('azure', AzureBackend);
CMS.registerBackend('github', GitHubBackend);
CMS.registerBackend('gitlab', GitLabBackend);
+CMS.registerBackend('gitea', GiteaBackend);
CMS.registerBackend('bitbucket', BitbucketBackend);
CMS.registerBackend('test-repo', TestBackend);
CMS.registerBackend('proxy', ProxyBackend);
diff --git a/packages/decap-cms-backend-gitea/package.json b/packages/decap-cms-backend-gitea/package.json
new file mode 100644
index 000000000000..1bc3ac9ca394
--- /dev/null
+++ b/packages/decap-cms-backend-gitea/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "decap-cms-backend-gitea",
+ "description": "Gitea backend for Decap CMS",
+ "version": "3.0.2",
+ "repository": "https://github.com/decaporg/decap-cms/tree/master/packages/decap-cms-backend-gitea",
+ "bugs": "https://github.com/decaporg/decap-cms/issues",
+ "license": "MIT",
+ "module": "dist/esm/index.js",
+ "main": "dist/decap-cms-backend-gitea.js",
+ "keywords": [
+ "decap-cms",
+ "backend",
+ "gitea"
+ ],
+ "sideEffects": false,
+ "scripts": {
+ "develop": "yarn build:esm --watch",
+ "build": "cross-env NODE_ENV=production webpack",
+ "build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward --extensions \".js,.jsx,.ts,.tsx\""
+ },
+ "dependencies": {
+ "js-base64": "^3.0.0",
+ "semaphore": "^1.1.0"
+ },
+ "peerDependencies": {
+ "@emotion/react": "^11.11.1",
+ "@emotion/styled": "^11.11.0",
+ "immutable": "^3.7.6",
+ "lodash": "^4.17.11",
+ "decap-cms-lib-auth": "^3.0.0",
+ "decap-cms-lib-util": "^3.0.0",
+ "decap-cms-ui-default": "^3.0.0",
+ "prop-types": "^15.7.2",
+ "react": "^16.8.4 || ^17.0.0"
+ }
+ }
diff --git a/packages/decap-cms-backend-gitea/src/API.ts b/packages/decap-cms-backend-gitea/src/API.ts
new file mode 100644
index 000000000000..bc208007fbd1
--- /dev/null
+++ b/packages/decap-cms-backend-gitea/src/API.ts
@@ -0,0 +1,463 @@
+import { Base64 } from 'js-base64';
+import { trimStart, trim, result, partial, last, initial } from 'lodash';
+import {
+ APIError,
+ basename,
+ generateContentKey,
+ getAllResponses,
+ localForage,
+ parseContentKey,
+ readFileMetadata,
+ requestWithBackoff,
+ unsentRequest,
+} from 'decap-cms-lib-util';
+
+import type {
+ DataFile,
+ PersistOptions,
+ AssetProxy,
+ ApiRequest,
+ FetchError,
+} from 'decap-cms-lib-util';
+import type { Semaphore } from 'semaphore';
+import type {
+ FilesResponse,
+ GitGetBlobResponse,
+ GitGetTreeResponse,
+ GiteaUser,
+ GiteaRepository,
+ ReposListCommitsResponse,
+} from './types';
+
+export const API_NAME = 'Gitea';
+
+export interface Config {
+ apiRoot?: string;
+ token?: string;
+ branch?: string;
+ repo?: 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 {
+ 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[]>;
+};
+
+type MediaFile = {
+ sha: string;
+ path: string;
+};
+
+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;
+ _metadataSemaphore?: Semaphore;
+
+ commitAuthor?: {};
+
+ constructor(config: Config) {
+ this.apiRoot = config.apiRoot || 'https://try.gitea.io/api/v1';
+ this.token = config.token || '';
+ this.branch = config.branch || 'master';
+ 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; avatar_url: string }> {
+ if (!this._userPromise) {
+ this._userPromise = this.getUser();
+ }
+ return this._userPromise;
+ }
+
+ getUser() {
+ return this.request('/user') as Promise;
+ }
+
+ async hasWriteAccess() {
+ try {
+ const result: GiteaRepository = 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 = {
+ '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(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, stat: 'false' },
+ },
+ );
+ 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 } = {},
+ folderSupport?: boolean,
+ ): 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/or folders up to the required depth
+ .filter(
+ file =>
+ (!folderSupport ? file.type === 'blob' : true) &&
+ 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('[StaticCMS] 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
+ const files: (DataFile | AssetProxy)[] = mediaFiles.concat(dataFiles as any);
+ const operations = await this.getChangeFileOperations(files, this.branch);
+ return this.changeFiles(operations, options);
+ }
+
+ async changeFiles(operations: ChangeFileOperation[], options: PersistOptions) {
+ return (await this.request(`${this.repoURL}/contents`, {
+ method: 'POST',
+ body: JSON.stringify({
+ branch: this.branch,
+ files: operations,
+ message: options.commitMessage,
+ }),
+ })) 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 } = {}) {
+ /**
+ * We need to request the tree first to get the SHA. We use extended SHA-1
+ * syntax (:) 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 {
+ throw new APIError('Not Found', 404, API_NAME);
+ }
+ }
+
+ async deleteFiles(paths: string[], message: string) {
+ const operations: ChangeFileOperation[] = await Promise.all(
+ paths.map(async path => {
+ const sha = await this.getFileSha(path);
+
+ return {
+ operation: FileOperation.DELETE,
+ path,
+ sha,
+ } as ChangeFileOperation;
+ }),
+ );
+ this.changeFiles(operations, { commitMessage: message });
+ }
+
+ toBase64(str: string) {
+ return Promise.resolve(Base64.encode(str));
+ }
+}
diff --git a/packages/decap-cms-backend-gitea/src/AuthenticationPage.js b/packages/decap-cms-backend-gitea/src/AuthenticationPage.js
new file mode 100644
index 000000000000..c61584bcc8d4
--- /dev/null
+++ b/packages/decap-cms-backend-gitea/src/AuthenticationPage.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import styled from '@emotion/styled';
+import { PkceAuthenticator } from 'decap-cms-lib-auth';
+import { AuthenticationPage, Icon } from 'decap-cms-ui-default';
+
+const LoginButtonIcon = styled(Icon)`
+ margin-right: 18px;
+`;
+
+export default class GiteaAuthenticationPage extends React.Component {
+ static propTypes = {
+ inProgress: PropTypes.bool,
+ config: PropTypes.object.isRequired,
+ onLogin: PropTypes.func.isRequired,
+ t: PropTypes.func.isRequired,
+ };
+
+ state = {};
+
+ componentDidMount() {
+ const { base_url = 'https://try.gitea.io', app_id = '' } = this.props.config.backend;
+ this.auth = new PkceAuthenticator({
+ base_url,
+ auth_endpoint: 'login/oauth/authorize',
+ app_id,
+ auth_token_endpoint: 'login/oauth/access_token',
+ });
+ // Complete authentication if we were redirected back to from the provider.
+ this.auth.completeAuth((err, data) => {
+ if (err) {
+ this.setState({ loginError: err.toString() });
+ return;
+ } else if (data) {
+ this.props.onLogin(data);
+ }
+ });
+ }
+
+ handleLogin = e => {
+ e.preventDefault();
+ this.auth.authenticate({ scope: 'repository' }, (err, data) => {
+ if (err) {
+ this.setState({ loginError: err.toString() });
+ return;
+ }
+ this.props.onLogin(data);
+ });
+ };
+
+ render() {
+ const { inProgress, config, t } = this.props;
+ return (
+ (
+
+ {' '}
+ {inProgress ? t('auth.loggingIn') : t('auth.loginWithGitea')}
+
+ )}
+ t={t}
+ />
+ );
+ }
+}
diff --git a/packages/decap-cms-backend-gitea/src/__tests__/API.spec.js b/packages/decap-cms-backend-gitea/src/__tests__/API.spec.js
new file mode 100644
index 000000000000..8529e21d5949
--- /dev/null
+++ b/packages/decap-cms-backend-gitea/src/__tests__/API.spec.js
@@ -0,0 +1,388 @@
+import { Base64 } from 'js-base64';
+
+import API from '../API';
+
+global.fetch = jest.fn().mockRejectedValue(new Error('should not call fetch inside tests'));
+
+describe('gitea API', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ function mockAPI(api, responses) {
+ 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.clearAllMocks();
+ });
+
+ 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',
+ },
+ signal: expect.any(AbortSignal),
+ });
+ });
+
+ 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',
+ },
+ signal: expect.any(AbortSignal),
+ });
+ });
+ });
+
+ describe('persistFiles', () => {
+ it('should create a new commit', async () => {
+ 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': () => ({
+ commit: { sha: 'new-sha' },
+ files: [
+ {
+ path: 'content/posts/new-post.md',
+ },
+ {
+ path: 'content/posts/update-post.md',
+ },
+ ],
+ }),
+ };
+ mockAPI(api, responses);
+
+ const entry = {
+ dataFiles: [
+ {
+ slug: 'entry',
+ path: 'content/posts/new-post.md',
+ raw: 'content',
+ },
+ {
+ slug: 'entry',
+ sha: 'old-sha',
+ path: 'content/posts/update-post.md',
+ raw: 'content',
+ },
+ ],
+ assets: [],
+ };
+ await expect(
+ api.persistFiles(entry.dataFiles, entry.assets, {
+ commitMessage: 'commitMessage',
+ 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(3);
+
+ expect(api.request.mock.calls[0]).toEqual([
+ '/repos/owner/repo/git/trees/master:content%2Fposts',
+ ]);
+
+ expect(api.request.mock.calls[1]).toEqual([
+ '/repos/owner/repo/git/trees/master:content%2Fposts',
+ ]);
+
+ expect(api.request.mock.calls[2]).toEqual([
+ '/repos/owner/repo/contents',
+ {
+ method: 'POST',
+ body: JSON.stringify({
+ branch: 'master',
+ files: [
+ {
+ operation: 'create',
+ content: Base64.encode(entry.dataFiles[0].raw),
+ path: entry.dataFiles[0].path,
+ },
+ {
+ operation: 'update',
+ content: Base64.encode(entry.dataFiles[1].raw),
+ path: entry.dataFiles[1].path,
+ sha: entry.dataFiles[1].sha,
+ },
+ ],
+ message: 'commitMessage',
+ }),
+ },
+ ]);
+ });
+ });
+
+ describe('deleteFiles', () => {
+ it('should check if files exist and delete them', async () => {
+ const api = new API({ branch: 'master', repo: 'owner/repo' });
+
+ const responses = {
+ '/repos/owner/repo/git/trees/master:content%2Fposts': () => {
+ return {
+ tree: [
+ { path: 'delete-post-1.md', sha: 'old-sha-1' },
+ { path: 'delete-post-2.md', sha: 'old-sha-2' },
+ ],
+ };
+ },
+
+ '/repos/owner/repo/contents': () => ({
+ commit: { sha: 'new-sha' },
+ files: [
+ {
+ path: 'content/posts/delete-post-1.md',
+ },
+ {
+ path: 'content/posts/delete-post-2.md',
+ },
+ ],
+ }),
+ };
+ mockAPI(api, responses);
+
+ const deleteFiles = ['content/posts/delete-post-1.md', 'content/posts/delete-post-2.md'];
+
+ await api.deleteFiles(deleteFiles, 'commitMessage');
+
+ expect(api.request).toHaveBeenCalledTimes(3);
+
+ expect(api.request.mock.calls[0]).toEqual([
+ '/repos/owner/repo/git/trees/master:content%2Fposts',
+ ]);
+
+ expect(api.request.mock.calls[1]).toEqual([
+ '/repos/owner/repo/git/trees/master:content%2Fposts',
+ ]);
+
+ expect(api.request.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',
+ }),
+ },
+ ]);
+ });
+ });
+
+ 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 },
+ });
+ });
+ it('should get files and folders', async () => {
+ const api = new API({ branch: 'master', repo: 'owner/repo' });
+
+ const tree = [
+ {
+ path: 'image.png',
+ type: 'blob',
+ },
+ {
+ path: 'dir1',
+ type: 'tree',
+ },
+ {
+ path: 'dir1/nested-image.png',
+ type: 'blob',
+ },
+ {
+ path: 'dir1/dir2',
+ type: 'tree',
+ },
+ {
+ path: 'dir1/dir2/nested-image.png',
+ type: 'blob',
+ },
+ ];
+ api.request = jest.fn().mockResolvedValue({ tree });
+
+ await expect(api.listFiles('media', {}, true)).resolves.toEqual([
+ {
+ path: 'media/image.png',
+ type: 'blob',
+ name: 'image.png',
+ },
+ {
+ path: 'media/dir1',
+ type: 'tree',
+ name: 'dir1',
+ },
+ ]);
+ expect(api.request).toHaveBeenCalledTimes(1);
+ expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:media', {
+ params: {},
+ });
+ });
+ });
+});
diff --git a/packages/decap-cms-backend-gitea/src/__tests__/implementation.spec.js b/packages/decap-cms-backend-gitea/src/__tests__/implementation.spec.js
new file mode 100644
index 000000000000..cc44d2dca62e
--- /dev/null
+++ b/packages/decap-cms-backend-gitea/src/__tests__/implementation.spec.js
@@ -0,0 +1,284 @@
+import { Cursor, CURSOR_COMPATIBILITY_SYMBOL } from 'decap-cms-lib-util';
+
+import GiteaImplementation from '../implementation';
+
+jest.spyOn(console, 'error').mockImplementation(() => {});
+
+describe('gitea backend implementation', () => {
+ const config = {
+ backend: {
+ repo: 'owner/repo',
+ api_root: 'https://try.gitea.io/api/v1',
+ },
+ };
+
+ const createObjectURL = jest.fn();
+ global.URL = {
+ createObjectURL,
+ };
+
+ createObjectURL.mockReturnValue('displayURL');
+
+ beforeAll(() => {
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('persistMedia', () => {
+ const persistFiles = jest.fn();
+ const mockAPI = {
+ persistFiles,
+ };
+
+ persistFiles.mockImplementation((_, files) => {
+ 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',
+ };
+
+ 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',
+ };
+
+ 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',
+ };
+
+ 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[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,
+ };
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ 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`,
+ });
+ }
+
+ 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,
+ });
+ });
+ });
+});
diff --git a/packages/decap-cms-backend-gitea/src/implementation.tsx b/packages/decap-cms-backend-gitea/src/implementation.tsx
new file mode 100644
index 000000000000..8584dffdf935
--- /dev/null
+++ b/packages/decap-cms-backend-gitea/src/implementation.tsx
@@ -0,0 +1,450 @@
+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 'decap-cms-lib-util';
+
+import API, { API_NAME } from './API';
+import AuthenticationPage from './AuthenticationPage';
+
+import type {
+ AssetProxy,
+ AsyncLock,
+ Config,
+ Credentials,
+ DisplayURL,
+ Entry,
+ Implementation,
+ ImplementationFile,
+ PersistOptions,
+ User,
+} from 'decap-cms-lib-util';
+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 Implementation {
+ lock: AsyncLock;
+ api: API | null;
+ options: {
+ proxied: boolean;
+ API: API | null;
+ useWorkflow?: boolean;
+ };
+ originRepo: string;
+ repo?: string;
+ branch: string;
+ apiRoot: string;
+ mediaFolder?: string;
+ token: string | null;
+ _currentUserPromise?: Promise;
+ _userIsOriginMaintainerPromises?: {
+ [key: string]: Promise;
+ };
+ _mediaDisplayURLSem?: Semaphore;
+
+ constructor(config: Config, options = {}) {
+ this.options = {
+ proxied: false,
+ API: null,
+ useWorkflow: false,
+ ...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.');
+ }
+
+ if (this.options.useWorkflow) {
+ throw new Error('The Gitea backend does not support editorial workflow.');
+ }
+
+ this.api = this.options.API || null;
+ this.repo = this.originRepo = config.backend.repo || '';
+ this.branch = config.backend.branch?.trim() || 'master';
+ this.apiRoot = config.backend.api_root || 'https://try.gitea.io/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('[StaticCMS] 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 {
+ name: user.full_name,
+ login: user.login,
+ avatar_url: user.avatar_url,
+ 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;
+
+ 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;
+ };
+
+ 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;
+
+ 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, folderSupport?: boolean) {
+ if (!mediaFolder) {
+ return [];
+ }
+ return this.api!.listFiles(mediaFolder, undefined, folderSupport).then(files =>
+ files.map(({ id, name, size, path, type }) => {
+ return { id, name, size, displayURL: { id, path }, path, isDirectory: type === 'tree' };
+ }),
+ );
+ }
+
+ 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: Entry, 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!.get('files')!.toJS() 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.get('pageCount'));
+ break;
+ }
+ case 'next': {
+ result = this.getCursorAndFiles(files, meta.get('page') + 1);
+ break;
+ }
+ case 'prev': {
+ result = this.getCursorAndFiles(files, meta.get('page') - 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;
+
+ const entries = await entriesByFiles(
+ result.files,
+ readFile,
+ this.api!.readFileMetadata.bind(this.api),
+ API_NAME,
+ );
+
+ return {
+ entries,
+ cursor: result.cursor,
+ };
+ }
+
+ async unpublishedEntries() {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return {} as any;
+ }
+
+ async unpublishedEntry() {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return {} as any;
+ }
+
+ async unpublishedEntryDataFile() {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return {} as any;
+ }
+
+ async unpublishedEntryMediaFile() {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return {} as any;
+ }
+
+ async updateUnpublishedEntryStatus() {
+ return;
+ }
+
+ async publishUnpublishedEntry() {
+ return;
+ }
+ async deleteUnpublishedEntry() {
+ return;
+ }
+
+ async getDeployPreview() {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return {} as any;
+ }
+}
diff --git a/packages/decap-cms-backend-gitea/src/index.ts b/packages/decap-cms-backend-gitea/src/index.ts
new file mode 100644
index 000000000000..0b8f05b93f72
--- /dev/null
+++ b/packages/decap-cms-backend-gitea/src/index.ts
@@ -0,0 +1,3 @@
+export { default as GiteaBackend } from './implementation';
+export { default as API } from './API';
+export { default as AuthenticationPage } from './AuthenticationPage';
diff --git a/packages/decap-cms-backend-gitea/src/types.ts b/packages/decap-cms-backend-gitea/src/types.ts
new file mode 100644
index 000000000000..3f45b4499375
--- /dev/null
+++ b/packages/decap-cms-backend-gitea/src/types.ts
@@ -0,0 +1,260 @@
+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;
+ units_map: Map;
+};
+
+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 CommitUser = {
+ date: string;
+ email: string;
+ name: string;
+};
+
+type CommitMeta = {
+ 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: CommitUser;
+ committer: CommitUser;
+ message: string;
+ tree: CommitMeta;
+ url: string;
+ verification: PayloadCommitVerification;
+};
+
+type GiteaRepositoryPermissions = {
+ admin: boolean;
+ pull: boolean;
+ push: boolean;
+};
+
+type GiteaRepositoryExternalTracker = {
+ external_tracker_format: string;
+ external_tracker_regexp_pattern: string;
+ external_tracker_style: string;
+ external_tracker_url: string;
+};
+
+type GiteaRepositoryExternalWiki = {
+ external_wiki_url: string;
+};
+
+type GiteaRepositoryInternalTracker = {
+ allow_only_contributors_to_track_time: boolean;
+ enable_issue_dependencies: boolean;
+ enable_time_tracker: boolean;
+};
+
+type GiteaRepositoryRepoTransfer = {
+ description: string;
+ doer: GiteaUser;
+ recipient: GiteaUser;
+ teams: Array;
+ enable_issue_dependencies: boolean;
+ enable_time_tracker: boolean;
+};
+
+export type GiteaRepository = {
+ 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: GiteaRepositoryExternalTracker;
+ external_wiki: GiteaRepositoryExternalWiki;
+ 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: GiteaRepositoryInternalTracker;
+ 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: GiteaRepositoryPermissions;
+ private: boolean;
+ release_counter: number;
+ repo_transfer: GiteaRepositoryRepoTransfer;
+ 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;
+ html_url: string;
+ parents: Array;
+ sha: string;
+ stats: ReposListCommitsResponseItemCommitStats;
+ url: string;
+};
+
+export type ReposListCommitsResponse = Array;
+
+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;
+ truncated: boolean;
+ url: string;
+};
+
+type FileLinksResponse = {
+ git: string;
+ html: string;
+ self: string;
+};
+
+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;
+};
+
+type FileCommitResponse = {
+ author: CommitUser;
+ committer: CommitUser;
+ created: string;
+ html_url: string;
+ message: string;
+ parents: Array;
+ sha: string;
+ tree: CommitMeta;
+ url: string;
+};
+
+export type FilesResponse = {
+ commit: FileCommitResponse;
+ content: Array;
+ verification: PayloadCommitVerification;
+};
diff --git a/packages/decap-cms-backend-gitea/webpack.config.js b/packages/decap-cms-backend-gitea/webpack.config.js
new file mode 100644
index 000000000000..42edd361d4a7
--- /dev/null
+++ b/packages/decap-cms-backend-gitea/webpack.config.js
@@ -0,0 +1,3 @@
+const { getConfig } = require('../../scripts/webpack.js');
+
+module.exports = getConfig();
diff --git a/packages/decap-cms-core/index.d.ts b/packages/decap-cms-core/index.d.ts
index a8d96b9c2dd9..2fcd45aa83c4 100644
--- a/packages/decap-cms-core/index.d.ts
+++ b/packages/decap-cms-core/index.d.ts
@@ -9,6 +9,7 @@ declare module 'decap-cms-core' {
| 'git-gateway'
| 'github'
| 'gitlab'
+ | 'gitea'
| 'bitbucket'
| 'test-repo'
| 'proxy';
diff --git a/packages/decap-cms-core/src/types/redux.ts b/packages/decap-cms-core/src/types/redux.ts
index b860ddcf41e8..b69a82311532 100644
--- a/packages/decap-cms-core/src/types/redux.ts
+++ b/packages/decap-cms-core/src/types/redux.ts
@@ -17,6 +17,7 @@ export type CmsBackendType =
| 'git-gateway'
| 'github'
| 'gitlab'
+ | 'gitea'
| 'bitbucket'
| 'test-repo'
| 'proxy';
diff --git a/packages/decap-cms-lib-auth/src/pkce-oauth.js b/packages/decap-cms-lib-auth/src/pkce-oauth.js
index ab3c22d46e7a..1928f1a85489 100644
--- a/packages/decap-cms-lib-auth/src/pkce-oauth.js
+++ b/packages/decap-cms-lib-auth/src/pkce-oauth.js
@@ -105,19 +105,25 @@ export default class PkceAuthenticator {
if (params.has('code')) {
const code = params.get('code');
const authURL = new URL(this.auth_token_url);
- authURL.searchParams.set('client_id', this.appID);
- authURL.searchParams.set('code', code);
- authURL.searchParams.set('grant_type', 'authorization_code');
- authURL.searchParams.set(
- 'redirect_uri',
- document.location.origin + document.location.pathname,
- );
- authURL.searchParams.set('code_verifier', getCodeVerifier());
+
+ const response = await fetch(authURL.href, {
+ method: 'POST',
+ body: JSON.stringify({
+ client_id: this.appID,
+ code,
+ grant_type: 'authorization_code',
+ redirect_uri: document.location.origin + document.location.pathname,
+ code_verifier: getCodeVerifier(),
+ }),
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8',
+ },
+ });
+ const data = await response.json();
+
//no need for verifier code so remove
clearCodeVerifier();
- const response = await fetch(authURL.href, { method: 'POST' });
- const data = await response.json();
cb(null, { token: data.access_token, ...data });
}
}
diff --git a/packages/decap-cms-locales/src/bg/index.js b/packages/decap-cms-locales/src/bg/index.js
index 5af3b0e86303..f047e9f826a6 100644
--- a/packages/decap-cms-locales/src/bg/index.js
+++ b/packages/decap-cms-locales/src/bg/index.js
@@ -7,6 +7,7 @@ const bg = {
loginWithBitbucket: 'Вход с Bitbucket',
loginWithGitHub: 'Вход с GitHub',
loginWithGitLab: 'Вход с GitLab',
+ loginWithGitea: 'Вход с Gitea',
errors: {
email: 'Въведете вашия имейл.',
password: 'Въведете паролата.',
diff --git a/packages/decap-cms-locales/src/ca/index.js b/packages/decap-cms-locales/src/ca/index.js
index 7e509daf8050..cd4fc94f2a59 100644
--- a/packages/decap-cms-locales/src/ca/index.js
+++ b/packages/decap-cms-locales/src/ca/index.js
@@ -6,6 +6,7 @@ const ca = {
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.',
diff --git a/packages/decap-cms-locales/src/cs/index.js b/packages/decap-cms-locales/src/cs/index.js
index 2e635fc4cda9..96cfcd52c50f 100644
--- a/packages/decap-cms-locales/src/cs/index.js
+++ b/packages/decap-cms-locales/src/cs/index.js
@@ -7,6 +7,7 @@ const cs = {
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.',
diff --git a/packages/decap-cms-locales/src/da/index.js b/packages/decap-cms-locales/src/da/index.js
index 3ecc16cedc46..700352eb281f 100644
--- a/packages/decap-cms-locales/src/da/index.js
+++ b/packages/decap-cms-locales/src/da/index.js
@@ -7,6 +7,7 @@ const da = {
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.',
diff --git a/packages/decap-cms-locales/src/de/index.js b/packages/decap-cms-locales/src/de/index.js
index 9dbd79e57035..5e96416f2776 100644
--- a/packages/decap-cms-locales/src/de/index.js
+++ b/packages/decap-cms-locales/src/de/index.js
@@ -7,6 +7,7 @@ const de = {
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.',
diff --git a/packages/decap-cms-locales/src/en/index.js b/packages/decap-cms-locales/src/en/index.js
index 400eaf6be0af..f3f968cd0acb 100644
--- a/packages/decap-cms-locales/src/en/index.js
+++ b/packages/decap-cms-locales/src/en/index.js
@@ -7,6 +7,7 @@ const en = {
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.',
diff --git a/packages/decap-cms-locales/src/es/index.js b/packages/decap-cms-locales/src/es/index.js
index d8d1558e1b30..6f49732c1b79 100644
--- a/packages/decap-cms-locales/src/es/index.js
+++ b/packages/decap-cms-locales/src/es/index.js
@@ -6,6 +6,7 @@ const es = {
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.',
diff --git a/packages/decap-cms-locales/src/fa/index.js b/packages/decap-cms-locales/src/fa/index.js
index fca5173cc277..7688f3605310 100644
--- a/packages/decap-cms-locales/src/fa/index.js
+++ b/packages/decap-cms-locales/src/fa/index.js
@@ -7,6 +7,7 @@ const fa = {
loginWithBitbucket: 'با Bitbucket وارد شوید',
loginWithGitHub: 'با GitHub وارد شوید',
loginWithGitLab: 'با GitLab وارد شوید',
+ loginWithGitea: 'با Gitea وارد شوید',
errors: {
email: 'ایمیل خود را حتما وارد کنید.',
password: 'لطفا رمز عبور خود را وارد کنید.',
diff --git a/packages/decap-cms-locales/src/fr/index.js b/packages/decap-cms-locales/src/fr/index.js
index 206710fadc67..ba160efea89f 100644
--- a/packages/decap-cms-locales/src/fr/index.js
+++ b/packages/decap-cms-locales/src/fr/index.js
@@ -7,6 +7,7 @@ const fr = {
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.',
diff --git a/packages/decap-cms-locales/src/gr/index.js b/packages/decap-cms-locales/src/gr/index.js
index 9b52888cb84c..6fd304026d50 100644
--- a/packages/decap-cms-locales/src/gr/index.js
+++ b/packages/decap-cms-locales/src/gr/index.js
@@ -6,6 +6,7 @@ const gr = {
loginWithBitbucket: 'Σύνδεση μέσω Bitbucket',
loginWithGitHub: 'Σύνδεση μέσω GitHub',
loginWithGitLab: 'Σύνδεση μέσω GitLab',
+ loginWithGitea: 'Σύνδεση μέσω Gitea',
errors: {
email: 'Βεβαιωθείτε ότι έχετε εισαγάγει το email σας.',
password: 'Παρακαλώ εισάγετε τον κωδικό πρόσβασής σας.',
diff --git a/packages/decap-cms-locales/src/he/index.js b/packages/decap-cms-locales/src/he/index.js
index 03c20edbd992..18237423173b 100644
--- a/packages/decap-cms-locales/src/he/index.js
+++ b/packages/decap-cms-locales/src/he/index.js
@@ -7,6 +7,7 @@ const he = {
loginWithBitbucket: 'התחברות עם Bitbucket',
loginWithGitHub: 'התחברות עם GitHub',
loginWithGitLab: 'התחברות עם GitLab',
+ loginWithGitea: 'התחברות עם Gitea',
errors: {
email: 'נא לא לשכוח להקליד את כתובת המייל',
password: 'נא להקליד את הסיסמה.',
diff --git a/packages/decap-cms-locales/src/hr/index.js b/packages/decap-cms-locales/src/hr/index.js
index 1cb9ab9b75db..aa6c463b7017 100644
--- a/packages/decap-cms-locales/src/hr/index.js
+++ b/packages/decap-cms-locales/src/hr/index.js
@@ -7,6 +7,7 @@ const hr = {
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.',
diff --git a/packages/decap-cms-locales/src/it/index.js b/packages/decap-cms-locales/src/it/index.js
index ef72c4bf4b76..98c601d04fe9 100644
--- a/packages/decap-cms-locales/src/it/index.js
+++ b/packages/decap-cms-locales/src/it/index.js
@@ -6,6 +6,7 @@ const it = {
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.',
diff --git a/packages/decap-cms-locales/src/ja/index.js b/packages/decap-cms-locales/src/ja/index.js
index bc020081fca3..db7ba980f041 100644
--- a/packages/decap-cms-locales/src/ja/index.js
+++ b/packages/decap-cms-locales/src/ja/index.js
@@ -7,6 +7,7 @@ const ja = {
loginWithBitbucket: 'Bitbucket でログインする',
loginWithGitHub: 'GitHub でログインする',
loginWithGitLab: 'GitLab でログインする',
+ loginWithGitea: 'Gitea でログインする',
errors: {
email: 'メールアドレスを確認してください。',
password: 'パスワードを入力してください。',
diff --git a/packages/decap-cms-locales/src/ko/index.js b/packages/decap-cms-locales/src/ko/index.js
index ed3c2c34e7a6..6e8dba0bf997 100644
--- a/packages/decap-cms-locales/src/ko/index.js
+++ b/packages/decap-cms-locales/src/ko/index.js
@@ -7,6 +7,7 @@ const ko = {
loginWithBitbucket: 'Bitbucket 으로 로그인',
loginWithGitHub: 'GitHub 로 로그인',
loginWithGitLab: 'GitLab 으로 로그인',
+ loginWithGitea: 'Gitea 으로 로그인',
errors: {
email: '반드시 이메일을 입력해 주세요.',
password: '암호를 입력해 주세요.',
diff --git a/packages/decap-cms-locales/src/lt/index.js b/packages/decap-cms-locales/src/lt/index.js
index d22811ec0ab9..1b3ce684cfa6 100644
--- a/packages/decap-cms-locales/src/lt/index.js
+++ b/packages/decap-cms-locales/src/lt/index.js
@@ -7,6 +7,7 @@ const lt = {
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į.',
diff --git a/packages/decap-cms-locales/src/nb_no/index.js b/packages/decap-cms-locales/src/nb_no/index.js
index 72ce9faa83f7..d1fa0e2ca5c8 100644
--- a/packages/decap-cms-locales/src/nb_no/index.js
+++ b/packages/decap-cms-locales/src/nb_no/index.js
@@ -6,6 +6,7 @@ const nb_no = {
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.',
diff --git a/packages/decap-cms-locales/src/nl/index.js b/packages/decap-cms-locales/src/nl/index.js
index ba4b96b5412a..6baefaad427b 100644
--- a/packages/decap-cms-locales/src/nl/index.js
+++ b/packages/decap-cms-locales/src/nl/index.js
@@ -7,6 +7,7 @@ const nl = {
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.',
diff --git a/packages/decap-cms-locales/src/nn_no/index.js b/packages/decap-cms-locales/src/nn_no/index.js
index 6be99524a14f..c59391d8e159 100644
--- a/packages/decap-cms-locales/src/nn_no/index.js
+++ b/packages/decap-cms-locales/src/nn_no/index.js
@@ -6,6 +6,7 @@ const nn_no = {
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.',
diff --git a/packages/decap-cms-locales/src/pl/index.js b/packages/decap-cms-locales/src/pl/index.js
index 6d6eba0e6926..3331e2eecf7b 100644
--- a/packages/decap-cms-locales/src/pl/index.js
+++ b/packages/decap-cms-locales/src/pl/index.js
@@ -7,6 +7,7 @@ const pl = {
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',
diff --git a/packages/decap-cms-locales/src/pt/index.js b/packages/decap-cms-locales/src/pt/index.js
index 96ee92055b2a..0031761df1aa 100644
--- a/packages/decap-cms-locales/src/pt/index.js
+++ b/packages/decap-cms-locales/src/pt/index.js
@@ -7,6 +7,7 @@ const pt = {
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.',
diff --git a/packages/decap-cms-locales/src/ro/index.js b/packages/decap-cms-locales/src/ro/index.js
index 266101b51531..4a4a88294ffb 100644
--- a/packages/decap-cms-locales/src/ro/index.js
+++ b/packages/decap-cms-locales/src/ro/index.js
@@ -7,6 +7,7 @@ const ro = {
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.',
diff --git a/packages/decap-cms-locales/src/ru/index.js b/packages/decap-cms-locales/src/ru/index.js
index 28b511510df9..f2ec4c3ab020 100644
--- a/packages/decap-cms-locales/src/ru/index.js
+++ b/packages/decap-cms-locales/src/ru/index.js
@@ -7,6 +7,7 @@ const ru = {
loginWithBitbucket: 'Войти через Bitbucket',
loginWithGitHub: 'Войти через GitHub',
loginWithGitLab: 'Войти через GitLab',
+ loginWithGitea: 'Войти через Gitea',
errors: {
email: 'Введите ваш email.',
password: 'Введите пароль.',
diff --git a/packages/decap-cms-locales/src/sv/index.js b/packages/decap-cms-locales/src/sv/index.js
index ef63f30e4717..e874cdb41b5e 100644
--- a/packages/decap-cms-locales/src/sv/index.js
+++ b/packages/decap-cms-locales/src/sv/index.js
@@ -7,6 +7,7 @@ const sv = {
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.',
diff --git a/packages/decap-cms-locales/src/th/index.js b/packages/decap-cms-locales/src/th/index.js
index d71b828d7d6d..89e8a36e277f 100644
--- a/packages/decap-cms-locales/src/th/index.js
+++ b/packages/decap-cms-locales/src/th/index.js
@@ -6,6 +6,7 @@ const th = {
loginWithBitbucket: 'เข้าสู่ระบบด้วย Bitbucket',
loginWithGitHub: 'เข้าสู่ระบบด้วย GitHub',
loginWithGitLab: 'เข้าสู่ระบบด้วย GitLab',
+ loginWithGitea: 'เข้าสู่ระบบด้วย Gitea',
errors: {
email: 'ตรวจสอบให้แน่ใจว่าได้ใส่อีเมลล์แล้ว',
password: 'โปรดใส่รหัสผ่านของคุณ',
diff --git a/packages/decap-cms-locales/src/tr/index.js b/packages/decap-cms-locales/src/tr/index.js
index 506e356293fa..1d46b2b215f0 100644
--- a/packages/decap-cms-locales/src/tr/index.js
+++ b/packages/decap-cms-locales/src/tr/index.js
@@ -7,6 +7,7 @@ const tr = {
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.',
diff --git a/packages/decap-cms-locales/src/ua/index.js b/packages/decap-cms-locales/src/ua/index.js
index 66b9e1f97338..d14b097c8453 100644
--- a/packages/decap-cms-locales/src/ua/index.js
+++ b/packages/decap-cms-locales/src/ua/index.js
@@ -7,6 +7,7 @@ const ua = {
loginWithBitbucket: 'Увійти через Bitbucket',
loginWithGitHub: 'Увійти через GitHub',
loginWithGitLab: 'Увійти через GitLab',
+ loginWithGitea: 'Увійти через Gitea',
errors: {
email: 'Введіть ваш email.',
password: 'Введіть пароль.',
diff --git a/packages/decap-cms-locales/src/vi/index.js b/packages/decap-cms-locales/src/vi/index.js
index 2925ab1f20aa..8c5c71789ad8 100644
--- a/packages/decap-cms-locales/src/vi/index.js
+++ b/packages/decap-cms-locales/src/vi/index.js
@@ -6,6 +6,7 @@ const vi = {
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.',
diff --git a/packages/decap-cms-locales/src/zh_Hans/index.js b/packages/decap-cms-locales/src/zh_Hans/index.js
index c54a8b6549eb..e0059feeff25 100644
--- a/packages/decap-cms-locales/src/zh_Hans/index.js
+++ b/packages/decap-cms-locales/src/zh_Hans/index.js
@@ -7,6 +7,7 @@ const zh_Hans = {
loginWithBitbucket: '使用 Bitbucket 登录',
loginWithGitHub: '使用 GitHub 登录',
loginWithGitLab: '使用 GitLab 登录',
+ loginWithGitea: '使用 Gitea 登录',
errors: {
email: '请输入电子邮箱',
password: '请输入密码',
diff --git a/packages/decap-cms-locales/src/zh_Hant/index.js b/packages/decap-cms-locales/src/zh_Hant/index.js
index 531faefa7fe1..1856ce1d6c12 100644
--- a/packages/decap-cms-locales/src/zh_Hant/index.js
+++ b/packages/decap-cms-locales/src/zh_Hant/index.js
@@ -6,6 +6,7 @@ const zh_Hant = {
loginWithBitbucket: '使用你的 Bitbucket 帳號來進行登入',
loginWithGitHub: '使用你的 GitHub 帳號來進行登入',
loginWithGitLab: '使用你的 GitLab 帳號來進行登入',
+ loginWithGitea: '使用你的 Gitea 帳號來進行登入',
errors: {
email: '請確認你已經輸入你的電子郵件。',
password: '請輸入你的密碼。',
diff --git a/packages/decap-cms-ui-default/src/Icon/images/_index.js b/packages/decap-cms-ui-default/src/Icon/images/_index.js
index cc229e2faa2b..1a80a6260a9e 100644
--- a/packages/decap-cms-ui-default/src/Icon/images/_index.js
+++ b/packages/decap-cms-ui-default/src/Icon/images/_index.js
@@ -16,6 +16,7 @@ import iconEye from './eye.svg';
import iconFolder from './folder.svg';
import iconGithub from './github.svg';
import iconGitlab from './gitlab.svg';
+import iconGitea from './gitea.svg';
import iconGrid from './grid.svg';
import iconH1 from './h1.svg';
import iconH2 from './h2.svg';
@@ -66,6 +67,7 @@ const images = {
folder: iconFolder,
github: iconGithub,
gitlab: iconGitlab,
+ gitea: iconGitea,
grid: iconGrid,
h1: iconH1,
h2: iconH2,
diff --git a/packages/decap-cms-ui-default/src/Icon/images/gitea.svg b/packages/decap-cms-ui-default/src/Icon/images/gitea.svg
new file mode 100644
index 000000000000..9f82c8e1750e
--- /dev/null
+++ b/packages/decap-cms-ui-default/src/Icon/images/gitea.svg
@@ -0,0 +1,47 @@
+
+
diff --git a/website/content/docs/backends-overview.md b/website/content/docs/backends-overview.md
index 030c3e51f4a2..de88391802d8 100644
--- a/website/content/docs/backends-overview.md
+++ b/website/content/docs/backends-overview.md
@@ -12,11 +12,11 @@ Individual backends should provide their own configuration documentation, but th
| Field | Default | Description |
| --------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `repo` | none | **Required** for `github`, `gitlab`, and `bitbucket` backends; ignored by `git-gateway`. Follows the pattern `[org-or-username]/[repo-name]`. |
+| `repo` | none | **Required** for `github`, `gitlab`, `azure`, `gitea` and `bitbucket` backends; ignored by `git-gateway`. Follows the pattern `[org-or-username]/[repo-name]`. |
| `branch` | `master` | The branch where published content is stored. All CMS commits and PRs are made to this branch. |
-| `api_root` | `https://api.github.com` (GitHub), `https://gitlab.com/api/v4` (GitLab), or `https://api.bitbucket.org/2.0` (Bitbucket) | The API endpoint. Only necessary in certain cases, like with GitHub Enterprise or self-hosted GitLab. |
+| `api_root` | `https://api.github.com` (GitHub), `https://gitlab.com/api/v4` (GitLab), `https://try.gitea.io/api/v1` (Gitea) or `https://api.bitbucket.org/2.0` (Bitbucket) | The API endpoint. Only necessary in certain cases, like with GitHub Enterprise or self-hosted GitLab/Gitea. |
| `site_domain` | `location.hostname` (or `cms.netlify.com` when on `localhost`) | 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` | `https://api.netlify.com` (GitHub, Bitbucket) or `https://gitlab.com` (GitLab) | OAuth client hostname (just the base domain, no path). **Required** when using an external OAuth server or self-hosted GitLab. |
+| `base_url` | `https://api.netlify.com` (GitHub, Bitbucket), `https://gitlab.com` (GitLab) or `https://try.gitea.io` (Gitea) | OAuth client hostname (just the base domain, no path). **Required** when using an external OAuth server or self-hosted GitLab/Gitea. |
| `auth_endpoint` | `auth` (GitHub, Bitbucket) or `oauth/authorize` (GitLab) | Path to append to `base_url` for authentication requests. Optional. |
| `cms_label_prefix` | `decap-cms/` | Pull (or Merge) Requests label prefix when using editorial workflow. Optional. |
diff --git a/website/content/docs/gitea-backend.md b/website/content/docs/gitea-backend.md
new file mode 100644
index 000000000000..ac0fbe77f63f
--- /dev/null
+++ b/website/content/docs/gitea-backend.md
@@ -0,0 +1,31 @@
+---
+title: Gitea
+group: Accounts
+weight: 25
+---
+
+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.
+
+Please note that only Gitea **1.20** and upwards is supported due to API limitations in previous versions.
+
+## Authentication
+
+With Gitea's PKCE authorization, users can authenticate with Gitea directly from the client. To do this:
+
+1. Add your Decap 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 Decap 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:
+
+```yaml
+backend:
+ name: gitea
+ repo: owner-name/repo-name # Path to your Gitea repository
+ app_id: your-client-id # The Client ID provided by Gitea
+ api_root: https://gitea.example.com/api/v1 # API URL of your Gitea instance
+ base_url: https://gitea.example.com # Root URL of your Gitea instance
+ # optional, defaults to master
+ # branch: master
+```
+
+## Git Large File Storage (LFS)
+
+Please note that the Gitea backend **does not** support [git-lfs](https://git-lfs.github.com/).