From 0d89a58e93f64f868ff3e4e8f0945ccf166ad738 Mon Sep 17 00:00:00 2001 From: Denys Konovalov Date: Thu, 19 Oct 2023 15:48:57 +0200 Subject: [PATCH] feat(backend): add gitea backend (#6808) * feat(backend): add gitea backend * fix: cleanups, fixes, tests * fix: docs group * chore(naming): adapt gitea backend to new naming * fix: lint, build --------- Co-authored-by: Martin Jagodic Co-authored-by: Anze Demsar --- dev-test/backends/gitea/config.yml | 63 +++ dev-test/backends/gitea/index.html | 41 ++ packages/decap-cms-app/src/extensions.js | 2 + packages/decap-cms-backend-gitea/package.json | 36 ++ packages/decap-cms-backend-gitea/src/API.ts | 463 ++++++++++++++++++ .../src/AuthenticationPage.js | 70 +++ .../src/__tests__/API.spec.js | 388 +++++++++++++++ .../src/__tests__/implementation.spec.js | 284 +++++++++++ .../src/implementation.tsx | 450 +++++++++++++++++ packages/decap-cms-backend-gitea/src/index.ts | 3 + packages/decap-cms-backend-gitea/src/types.ts | 260 ++++++++++ .../decap-cms-backend-gitea/webpack.config.js | 3 + packages/decap-cms-core/index.d.ts | 1 + packages/decap-cms-core/src/types/redux.ts | 1 + packages/decap-cms-lib-auth/src/pkce-oauth.js | 26 +- packages/decap-cms-locales/src/bg/index.js | 1 + packages/decap-cms-locales/src/ca/index.js | 1 + packages/decap-cms-locales/src/cs/index.js | 1 + packages/decap-cms-locales/src/da/index.js | 1 + packages/decap-cms-locales/src/de/index.js | 1 + packages/decap-cms-locales/src/en/index.js | 1 + packages/decap-cms-locales/src/es/index.js | 1 + packages/decap-cms-locales/src/fa/index.js | 1 + packages/decap-cms-locales/src/fr/index.js | 1 + packages/decap-cms-locales/src/gr/index.js | 1 + packages/decap-cms-locales/src/he/index.js | 1 + packages/decap-cms-locales/src/hr/index.js | 1 + packages/decap-cms-locales/src/it/index.js | 1 + packages/decap-cms-locales/src/ja/index.js | 1 + packages/decap-cms-locales/src/ko/index.js | 1 + packages/decap-cms-locales/src/lt/index.js | 1 + packages/decap-cms-locales/src/nb_no/index.js | 1 + packages/decap-cms-locales/src/nl/index.js | 1 + packages/decap-cms-locales/src/nn_no/index.js | 1 + packages/decap-cms-locales/src/pl/index.js | 1 + packages/decap-cms-locales/src/pt/index.js | 1 + packages/decap-cms-locales/src/ro/index.js | 1 + packages/decap-cms-locales/src/ru/index.js | 1 + packages/decap-cms-locales/src/sv/index.js | 1 + packages/decap-cms-locales/src/th/index.js | 1 + packages/decap-cms-locales/src/tr/index.js | 1 + packages/decap-cms-locales/src/ua/index.js | 1 + packages/decap-cms-locales/src/vi/index.js | 1 + .../decap-cms-locales/src/zh_Hans/index.js | 1 + .../decap-cms-locales/src/zh_Hant/index.js | 1 + .../src/Icon/images/_index.js | 2 + .../src/Icon/images/gitea.svg | 47 ++ website/content/docs/backends-overview.md | 6 +- website/content/docs/gitea-backend.md | 31 ++ 49 files changed, 2194 insertions(+), 13 deletions(-) create mode 100644 dev-test/backends/gitea/config.yml create mode 100644 dev-test/backends/gitea/index.html create mode 100644 packages/decap-cms-backend-gitea/package.json create mode 100644 packages/decap-cms-backend-gitea/src/API.ts create mode 100644 packages/decap-cms-backend-gitea/src/AuthenticationPage.js create mode 100644 packages/decap-cms-backend-gitea/src/__tests__/API.spec.js create mode 100644 packages/decap-cms-backend-gitea/src/__tests__/implementation.spec.js create mode 100644 packages/decap-cms-backend-gitea/src/implementation.tsx create mode 100644 packages/decap-cms-backend-gitea/src/index.ts create mode 100644 packages/decap-cms-backend-gitea/src/types.ts create mode 100644 packages/decap-cms-backend-gitea/webpack.config.js create mode 100644 packages/decap-cms-ui-default/src/Icon/images/gitea.svg create mode 100644 website/content/docs/gitea-backend.md 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/).