From 63496b07eba8abf4964eedecbe86a4737930bc54 Mon Sep 17 00:00:00 2001 From: Denys Konovalov Date: Wed, 18 Jan 2023 00:08:45 +0100 Subject: [PATCH] feat: gitea backend (#325) --- packages/core/src/backends/gitea/API.ts | 437 ++++++++++++++++++ .../src/backends/gitea/AuthenticationPage.tsx | 64 +++ .../src/backends/gitea/__tests__/API.spec.ts | 267 +++++++++++ .../gitea/__tests__/implementation.spec.ts | 289 ++++++++++++ .../src/backends/gitea/implementation.tsx | 405 ++++++++++++++++ packages/core/src/backends/gitea/index.ts | 3 + packages/core/src/backends/gitea/types.ts | 247 ++++++++++ packages/core/src/backends/index.tsx | 1 + .../components/MediaLibrary/MediaLibrary.tsx | 2 +- .../src/components/UI/Icon/images/_index.tsx | 2 + .../src/components/UI/Icon/images/gitea.svg | 12 + packages/core/src/extensions.ts | 2 + packages/core/src/lib/auth/netlify-auth.ts | 4 + packages/core/src/locales/bg/index.ts | 1 + packages/core/src/locales/ca/index.ts | 1 + packages/core/src/locales/cs/index.ts | 1 + packages/core/src/locales/da/index.ts | 1 + packages/core/src/locales/de/index.ts | 3 + packages/core/src/locales/en/index.ts | 1 + packages/core/src/locales/es/index.ts | 1 + packages/core/src/locales/fr/index.ts | 1 + packages/core/src/locales/gr/index.ts | 1 + packages/core/src/locales/he/index.ts | 1 + packages/core/src/locales/hr/index.ts | 1 + packages/core/src/locales/it/index.ts | 1 + packages/core/src/locales/ja/index.ts | 1 + packages/core/src/locales/ko/index.ts | 1 + packages/core/src/locales/lt/index.ts | 1 + packages/core/src/locales/nb_no/index.ts | 1 + packages/core/src/locales/nl/index.ts | 1 + packages/core/src/locales/nn_no/index.ts | 1 + packages/core/src/locales/pl/index.ts | 1 + packages/core/src/locales/pt/index.ts | 1 + packages/core/src/locales/ro/index.ts | 1 + packages/core/src/locales/ru/index.ts | 1 + packages/core/src/locales/sv/index.ts | 1 + packages/core/src/locales/th/index.ts | 1 + packages/core/src/locales/tr/index.ts | 1 + packages/core/src/locales/vi/index.ts | 1 + packages/core/src/locales/zh_Hans/index.ts | 1 + packages/core/src/locales/zh_Hant/index.ts | 1 + .../docs/content/docs/backends-overview.mdx | 18 +- packages/docs/content/docs/gitea-backend.mdx | 48 ++ 43 files changed, 1821 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/backends/gitea/API.ts create mode 100644 packages/core/src/backends/gitea/AuthenticationPage.tsx create mode 100644 packages/core/src/backends/gitea/__tests__/API.spec.ts create mode 100644 packages/core/src/backends/gitea/__tests__/implementation.spec.ts create mode 100644 packages/core/src/backends/gitea/implementation.tsx create mode 100644 packages/core/src/backends/gitea/index.ts create mode 100644 packages/core/src/backends/gitea/types.ts create mode 100644 packages/core/src/components/UI/Icon/images/gitea.svg create mode 100644 packages/docs/content/docs/gitea-backend.mdx diff --git a/packages/core/src/backends/gitea/API.ts b/packages/core/src/backends/gitea/API.ts new file mode 100644 index 00000000..b5c8707f --- /dev/null +++ b/packages/core/src/backends/gitea/API.ts @@ -0,0 +1,437 @@ +import { Base64 } from 'js-base64'; +import initial from 'lodash/initial'; +import last from 'lodash/last'; +import partial from 'lodash/partial'; +import result from 'lodash/result'; +import trim from 'lodash/trim'; + +import { + APIError, + basename, + generateContentKey, + getAllResponses, + localForage, + parseContentKey, + readFileMetadata, + requestWithBackoff, + unsentRequest, +} from '@staticcms/core/lib/util'; + +import type { DataFile, PersistOptions } from '@staticcms/core/interface'; +import type { ApiRequest, FetchError } from '@staticcms/core/lib/util'; +import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; +import type { Semaphore } from 'semaphore'; +import type { + GitGetBlobResponse, + GitGetTreeResponse, + GiteaUser, + ReposGetResponse, + ReposListCommitsResponse, + ContentsResponse, +} from './types'; + +export const API_NAME = 'Gitea'; + +export interface Config { + apiRoot?: string; + token?: string; + branch?: string; + repo?: string; + originRepo?: string; +} + +interface MetaDataObjects { + entry: { path: string; sha: string }; + files: MediaFile[]; +} + +export interface Metadata { + type: string; + objects: MetaDataObjects; + branch: string; + status: string; + collection: string; + commitMessage: string; + version?: string; + user: string; + title?: string; + description?: string; + timeStamp: string; +} + +export interface BlobArgs { + sha: string; + repoURL: string; + parseText: boolean; +} + +type Param = string | number | undefined; + +export type Options = RequestInit & { + params?: Record | string[]>; +}; + +type MediaFile = { + sha: string; + path: string; +}; + +export type Diff = { + path: string; + newFile: boolean; + sha: string; + binary: boolean; +}; + +export default class API { + apiRoot: string; + token: string; + branch: string; + repo: string; + originRepo: string; + repoOwner: string; + repoName: string; + originRepoOwner: string; + originRepoName: string; + repoURL: string; + originRepoURL: string; + + _userPromise?: Promise; + _metadataSemaphore?: Semaphore; + + commitAuthor?: {}; + + constructor(config: Config) { + this.apiRoot = config.apiRoot || 'https://try.gitea.io/api/v1'; + this.token = config.token || ''; + this.branch = config.branch || 'main'; + this.repo = config.repo || ''; + this.originRepo = config.originRepo || this.repo; + this.repoURL = `/repos/${this.repo}`; + this.originRepoURL = `/repos/${this.originRepo}`; + + const [repoParts, originRepoParts] = [this.repo.split('/'), this.originRepo.split('/')]; + this.repoOwner = repoParts[0]; + this.repoName = repoParts[1]; + + this.originRepoOwner = originRepoParts[0]; + this.originRepoName = originRepoParts[1]; + } + + static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Static CMS'; + + user(): Promise<{ full_name: string; login: string }> { + if (!this._userPromise) { + this._userPromise = this.getUser(); + } + return this._userPromise; + } + + getUser() { + return this.request('/user') as Promise; + } + + async hasWriteAccess() { + try { + const result: ReposGetResponse = await this.request(this.repoURL); + // update config repoOwner to avoid case sensitivity issues with Gitea + this.repoOwner = result.owner.login; + return result.permissions.push; + } catch (error) { + console.error('Problem fetching repo data from Gitea'); + throw error; + } + } + + reset() { + // no op + } + + requestHeaders(headers = {}) { + const baseHeader: Record = { + '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 }, + }, + ); + const { commit } = result[0]; + return { + author: commit.author.name || commit.author.email, + updatedOn: commit.author.date, + }; + } catch (e) { + return { author: '', updatedOn: '' }; + } + }; + const fileMetadata = await readFileMetadata(sha, fetchFileMetadata, localForage); + return fileMetadata; + } + + async fetchBlobContent({ sha, repoURL, parseText }: BlobArgs) { + const result: GitGetBlobResponse = await this.request(`${repoURL}/git/blobs/${sha}`, { + cache: 'force-cache', + }); + + if (parseText) { + // treat content as a utf-8 string + const content = Base64.decode(result.content); + return content; + } else { + // treat content as binary and convert to blob + const content = Base64.atob(result.content); + const byteArray = new Uint8Array(content.length); + for (let i = 0; i < content.length; i++) { + byteArray[i] = content.charCodeAt(i); + } + const blob = new Blob([byteArray]); + return blob; + } + } + + async listFiles( + path: string, + { repoURL = this.repoURL, branch = this.branch, depth = 1 } = {}, + ): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> { + const folder = trim(path, '/'); + try { + const result: GitGetTreeResponse = await this.request( + `${repoURL}/git/trees/${branch}:${encodeURIComponent(folder)}`, + { + // Gitea API supports recursive=1 for getting the entire recursive tree + // or omitting it to get the non-recursive tree + params: depth > 1 ? { recursive: 1 } : {}, + }, + ); + return ( + result.tree + // filter only files and up to the required depth + .filter( + file => + file.type === 'blob' && decodeURIComponent(file.path).split('/').length <= depth, + ) + .map(file => ({ + type: file.type, + id: file.sha, + name: basename(file.path), + path: `${folder}/${file.path}`, + size: file.size!, + })) + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + if (err && err.status === 404) { + console.info('This 404 was expected and handled appropriately.'); + return []; + } else { + throw err; + } + } + } + + async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [mediaFiles, dataFiles].forEach(async list => { + list.forEach(async file => { + const item: { raw?: string; sha?: string; toBase64?: () => Promise } = file; + const contentBase64 = await result( + item, + 'toBase64', + partial(this.toBase64, item.raw as string), + ); + if (options.newEntry) { + await this.request(`${this.repoURL}/contents/${file.path}`, { + method: 'POST', + body: JSON.stringify({ + branch: this.branch, + content: contentBase64, + message: options.commitMessage, + signoff: false, + }), + }); + } else { + const oldSha = await this.getFileSha(file.path); + await this.request(`${this.repoURL}/contents/${file.path}`, { + method: 'PUT', + body: JSON.stringify({ + branch: this.branch, + content: contentBase64, + message: options.commitMessage, + sha: oldSha, + signoff: false, + }), + }); + } + }); + }); + } + + async getFileSha(path: string, { repoURL = this.repoURL, branch = this.branch } = {}) { + /** + * We need to request the tree first to get the SHA. We use extended SHA-1 + * syntax (:) to get a blob from a tree without having to recurse + * through the tree. + */ + + const pathArray = path.split('/'); + const filename = last(pathArray); + const directory = initial(pathArray).join('/'); + const fileDataPath = encodeURIComponent(directory); + const fileDataURL = `${repoURL}/git/trees/${branch}:${fileDataPath}`; + + const result: GitGetTreeResponse = await this.request(fileDataURL); + const file = result.tree.find(file => file.path === filename); + if (file) { + return file.sha; + } else { + console.error('File not found'); + } + } + + async deleteFiles(paths: string[], message: string) { + paths.forEach(async file => { + const meta: ContentsResponse = await this.request(`${this.repoURL}/contents/${file}`, { + method: 'GET', + }); + await this.request(`${this.repoURL}/contents/${file}`, { + method: 'DELETE', + body: JSON.stringify({ branch: this.branch, message, sha: meta.sha, signoff: false }), + }); + }); + } + + toBase64(str: string) { + return Promise.resolve(Base64.encode(str)); + } +} diff --git a/packages/core/src/backends/gitea/AuthenticationPage.tsx b/packages/core/src/backends/gitea/AuthenticationPage.tsx new file mode 100644 index 00000000..bcea8192 --- /dev/null +++ b/packages/core/src/backends/gitea/AuthenticationPage.tsx @@ -0,0 +1,64 @@ +import { styled } from '@mui/material/styles'; +import React, { useCallback, useState } from 'react'; + +import AuthenticationPage from '@staticcms/core/components/UI/AuthenticationPage'; +import Icon from '@staticcms/core/components/UI/Icon'; +import { NetlifyAuthenticator } from '@staticcms/core/lib/auth'; + +import type { MouseEvent } from 'react'; +import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface'; + +const LoginButtonIcon = styled(Icon)` + margin-right: 18px; +`; + +const GiteaAuthenticationPage = ({ + inProgress = false, + config, + base_url, + siteId, + authEndpoint, + onLogin, + t, +}: TranslatedProps) => { + const [loginError, setLoginError] = useState(null); + + const handleLogin = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + const cfg = { + base_url, + site_id: document.location.host.split(':')[0] === 'localhost' ? 'cms.netlify.com' : siteId, + auth_endpoint: authEndpoint, + }; + const auth = new NetlifyAuthenticator(cfg); + + const { auth_scope: authScope = '' } = config.backend; + + const scope = authScope || 'repo'; + auth.authenticate({ provider: 'gitea', scope }, (err, data) => { + if (err) { + setLoginError(err.toString()); + } else if (data) { + onLogin(data); + } + }); + }, + [authEndpoint, base_url, config.backend, onLogin, siteId], + ); + + return ( + } + buttonContent={t('auth.loginWithGitea')} + t={t} + /> + ); +}; + +export default GiteaAuthenticationPage; diff --git a/packages/core/src/backends/gitea/__tests__/API.spec.ts b/packages/core/src/backends/gitea/__tests__/API.spec.ts new file mode 100644 index 00000000..f2cb8d06 --- /dev/null +++ b/packages/core/src/backends/gitea/__tests__/API.spec.ts @@ -0,0 +1,267 @@ +import { Base64 } from 'js-base64'; + +import API from '../API'; + +import type { Options } from '../API'; + +describe('gitea API', () => { + beforeEach(() => { + jest.resetAllMocks(); + + global.fetch = jest.fn().mockRejectedValue(new Error('should not call fetch inside tests')); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function mockAPI(api: API, responses: Record any>) { + api.request = jest.fn().mockImplementation((path, options = {}) => { + const normalizedPath = path.indexOf('?') !== -1 ? path.slice(0, path.indexOf('?')) : path; + const response = responses[normalizedPath]; + return typeof response === 'function' + ? Promise.resolve(response(options)) + : Promise.reject(new Error(`No response for path '${normalizedPath}'`)); + }); + } + + describe('request', () => { + const fetch = jest.fn(); + beforeEach(() => { + global.fetch = fetch; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should fetch url with authorization header', async () => { + const api = new API({ branch: 'gh-pages', repo: 'my-repo', token: 'token' }); + + fetch.mockResolvedValue({ + text: jest.fn().mockResolvedValue('some response'), + ok: true, + status: 200, + headers: { get: () => '' }, + }); + const result = await api.request('/some-path'); + expect(result).toEqual('some response'); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith('https://try.gitea.io/api/v1/some-path', { + cache: 'no-cache', + headers: { + Authorization: 'token token', + 'Content-Type': 'application/json; charset=utf-8', + }, + }); + }); + + it('should throw error on not ok response', async () => { + const api = new API({ branch: 'gt-pages', repo: 'my-repo', token: 'token' }); + + fetch.mockResolvedValue({ + text: jest.fn().mockResolvedValue({ message: 'some error' }), + ok: false, + status: 404, + headers: { get: () => '' }, + }); + + await expect(api.request('some-path')).rejects.toThrow( + expect.objectContaining({ + message: 'some error', + name: 'API_ERROR', + status: 404, + api: 'Gitea', + }), + ); + }); + + it('should allow overriding requestHeaders to return a promise ', async () => { + const api = new API({ branch: 'gt-pages', repo: 'my-repo', token: 'token' }); + + api.requestHeaders = jest.fn().mockResolvedValue({ + Authorization: 'promise-token', + 'Content-Type': 'application/json; charset=utf-8', + }); + + fetch.mockResolvedValue({ + text: jest.fn().mockResolvedValue('some response'), + ok: true, + status: 200, + headers: { get: () => '' }, + }); + const result = await api.request('/some-path'); + expect(result).toEqual('some response'); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith('https://try.gitea.io/api/v1/some-path', { + cache: 'no-cache', + headers: { + Authorization: 'promise-token', + 'Content-Type': 'application/json; charset=utf-8', + }, + }); + }); + }); + + describe('persistFiles', () => { + it('should create a new file', async () => { + const api = new API({ branch: 'master', repo: 'owner/repo' }); + + const responses = { + '/repos/owner/repo/contents/content/posts/new-post.md': () => ({ + commit: { sha: 'new-sha' }, + }), + }; + mockAPI(api, responses); + + const entry = { + dataFiles: [ + { + slug: 'entry', + sha: 'abc', + path: 'content/posts/new-post.md', + raw: 'content', + }, + ], + assets: [], + }; + await api.persistFiles(entry.dataFiles, entry.assets, { + commitMessage: 'commitMessage', + newEntry: true, + }); + + expect(api.request).toHaveBeenCalledTimes(1); + + expect((api.request as jest.Mock).mock.calls[0]).toEqual([ + '/repos/owner/repo/contents/content/posts/new-post.md', + { + method: 'POST', + body: JSON.stringify({ + branch: 'master', + content: Base64.encode(entry.dataFiles[0].raw), + message: 'commitMessage', + signoff: false, + }), + }, + ]); + }); + it('should get the file sha and update the file', async () => { + jest.clearAllMocks(); + const api = new API({ branch: 'master', repo: 'owner/repo' }); + + const responses = { + '/repos/owner/repo/git/trees/master:content%2Fposts': () => { + return { tree: [{ path: 'update-post.md', sha: 'old-sha' }] }; + }, + + '/repos/owner/repo/contents/content/posts/update-post.md': () => { + return { commit: { sha: 'updated-sha' } }; + }, + }; + mockAPI(api, responses); + + const entry = { + dataFiles: [ + { + slug: 'entry', + sha: 'abc', + path: 'content/posts/update-post.md', + raw: 'content', + }, + ], + assets: [], + }; + + await api.persistFiles(entry.dataFiles, entry.assets, { + commitMessage: 'commitMessage', + newEntry: false, + }); + + expect(api.request).toHaveBeenCalledTimes(1); + + expect((api.request as jest.Mock).mock.calls[0]).toEqual([ + '/repos/owner/repo/git/trees/master:content%2Fposts', + ]); + }); + }); + + describe('listFiles', () => { + it('should get files by depth', async () => { + const api = new API({ branch: 'master', repo: 'owner/repo' }); + + const tree = [ + { + path: 'post.md', + type: 'blob', + }, + { + path: 'dir1', + type: 'tree', + }, + { + path: 'dir1/nested-post.md', + type: 'blob', + }, + { + path: 'dir1/dir2', + type: 'tree', + }, + { + path: 'dir1/dir2/nested-post.md', + type: 'blob', + }, + ]; + api.request = jest.fn().mockResolvedValue({ tree }); + + await expect(api.listFiles('posts', { depth: 1 })).resolves.toEqual([ + { + path: 'posts/post.md', + type: 'blob', + name: 'post.md', + }, + ]); + expect(api.request).toHaveBeenCalledTimes(1); + expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:posts', { + params: {}, + }); + + jest.clearAllMocks(); + await expect(api.listFiles('posts', { depth: 2 })).resolves.toEqual([ + { + path: 'posts/post.md', + type: 'blob', + name: 'post.md', + }, + { + path: 'posts/dir1/nested-post.md', + type: 'blob', + name: 'nested-post.md', + }, + ]); + expect(api.request).toHaveBeenCalledTimes(1); + expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:posts', { + params: { recursive: 1 }, + }); + + jest.clearAllMocks(); + await expect(api.listFiles('posts', { depth: 3 })).resolves.toEqual([ + { + path: 'posts/post.md', + type: 'blob', + name: 'post.md', + }, + { + path: 'posts/dir1/nested-post.md', + type: 'blob', + name: 'nested-post.md', + }, + { + path: 'posts/dir1/dir2/nested-post.md', + type: 'blob', + name: 'nested-post.md', + }, + ]); + expect(api.request).toHaveBeenCalledTimes(1); + expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:posts', { + params: { recursive: 1 }, + }); + }); + }); +}); diff --git a/packages/core/src/backends/gitea/__tests__/implementation.spec.ts b/packages/core/src/backends/gitea/__tests__/implementation.spec.ts new file mode 100644 index 00000000..4fcfebfd --- /dev/null +++ b/packages/core/src/backends/gitea/__tests__/implementation.spec.ts @@ -0,0 +1,289 @@ +import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from '@staticcms/core/lib/util/Cursor'; +import GiteaImplementation from '../implementation'; + +import type { Config, UnknownField } from '@staticcms/core'; +import type API from '../API'; +import type { AssetProxy } from '@staticcms/core/valueObjects'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +declare const global: any; + +describe('gitea backend implementation', () => { + const config = { + backend: { + repo: 'owner/repo', + api_root: 'https://try.gitea.io/api/v1', + }, + } as Config; + + const createObjectURL = jest.fn(); + global.URL = { + createObjectURL, + }; + + createObjectURL.mockReturnValue('displayURL'); + + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe('persistMedia', () => { + const persistFiles = jest.fn(); + const mockAPI = { + persistFiles, + } as unknown as API; + + persistFiles.mockImplementation((_, files: (AssetProxy & { sha: string })[]) => { + files.forEach((file, index) => { + file.sha = `${index}`; + }); + }); + + it('should persist media file', async () => { + const giteaImplementation = new GiteaImplementation(config); + giteaImplementation.api = mockAPI; + + const mediaFile = { + fileObj: { size: 100, name: 'image.png' }, + path: '/media/image.png', + } as AssetProxy; + + expect.assertions(5); + await expect( + giteaImplementation.persistMedia(mediaFile, { commitMessage: 'Persisting media' }), + ).resolves.toEqual({ + id: '0', + name: 'image.png', + size: 100, + displayURL: 'displayURL', + path: 'media/image.png', + }); + + expect(persistFiles).toHaveBeenCalledTimes(1); + expect(persistFiles).toHaveBeenCalledWith([], [mediaFile], { + commitMessage: 'Persisting media', + }); + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(createObjectURL).toHaveBeenCalledWith(mediaFile.fileObj); + }); + + it('should log and throw error on "persistFiles" error', async () => { + const giteaImplementation = new GiteaImplementation(config); + giteaImplementation.api = mockAPI; + + const error = new Error('failed to persist files'); + persistFiles.mockRejectedValue(error); + + const mediaFile = { + fileObj: { size: 100 }, + path: '/media/image.png', + } as AssetProxy; + + expect.assertions(5); + await expect( + giteaImplementation.persistMedia(mediaFile, { commitMessage: 'Persisting media' }), + ).rejects.toThrowError(error); + + expect(persistFiles).toHaveBeenCalledTimes(1); + expect(createObjectURL).toHaveBeenCalledTimes(0); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith(error); + }); + }); + + describe('entriesByFolder', () => { + const listFiles = jest.fn(); + const readFile = jest.fn(); + const readFileMetadata = jest.fn(() => Promise.resolve({ author: '', updatedOn: '' })); + + const mockAPI = { + listFiles, + readFile, + readFileMetadata, + originRepoURL: 'originRepoURL', + } as unknown as API; + + it('should return entries and cursor', async () => { + const giteaImplementation = new GiteaImplementation(config); + giteaImplementation.api = mockAPI; + + const files = []; + const count = 1501; + for (let i = 0; i < count; i++) { + const id = `${i}`.padStart(`${count}`.length, '0'); + files.push({ + id, + path: `posts/post-${id}.md`, + }); + } + + listFiles.mockResolvedValue(files); + readFile.mockImplementation((_path, id) => Promise.resolve(`${id}`)); + + const expectedEntries = files + .slice(0, 20) + .map(({ id, path }) => ({ data: id, file: { path, id, author: '', updatedOn: '' } })); + + const expectedCursor = Cursor.create({ + actions: ['next', 'last'], + meta: { page: 1, count, pageSize: 20, pageCount: 76 }, + data: { files }, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (expectedEntries as any)[CURSOR_COMPATIBILITY_SYMBOL] = expectedCursor; + + const result = await giteaImplementation.entriesByFolder('posts', 'md', 1); + + expect(result).toEqual(expectedEntries); + expect(listFiles).toHaveBeenCalledTimes(1); + expect(listFiles).toHaveBeenCalledWith('posts', { depth: 1, repoURL: 'originRepoURL' }); + expect(readFile).toHaveBeenCalledTimes(20); + }); + }); + + describe('traverseCursor', () => { + const listFiles = jest.fn(); + const readFile = jest.fn((_path, id) => Promise.resolve(`${id}`)); + const readFileMetadata = jest.fn(() => Promise.resolve({})); + + const mockAPI = { + listFiles, + readFile, + originRepoURL: 'originRepoURL', + readFileMetadata, + } as unknown as API; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const files: any[] = []; + const count = 1501; + for (let i = 0; i < count; i++) { + const id = `${i}`.padStart(`${count}`.length, '0'); + files.push({ + id, + path: `posts/post-${id}.md`, + }); + } + + it('should handle next action', async () => { + const giteaImplementation = new GiteaImplementation(config); + giteaImplementation.api = mockAPI; + + const cursor = Cursor.create({ + actions: ['next', 'last'], + meta: { page: 1, count, pageSize: 20, pageCount: 76 }, + data: { files }, + }); + + const expectedEntries = files + .slice(20, 40) + .map(({ id, path }) => ({ data: id, file: { path, id } })); + + const expectedCursor = Cursor.create({ + actions: ['prev', 'first', 'next', 'last'], + meta: { page: 2, count, pageSize: 20, pageCount: 76 }, + data: { files }, + }); + + const result = await giteaImplementation.traverseCursor(cursor, 'next'); + + expect(result).toEqual({ + entries: expectedEntries, + cursor: expectedCursor, + }); + }); + + it('should handle prev action', async () => { + const giteaImplementation = new GiteaImplementation(config); + giteaImplementation.api = mockAPI; + + const cursor = Cursor.create({ + actions: ['prev', 'first', 'next', 'last'], + meta: { page: 2, count, pageSize: 20, pageCount: 76 }, + data: { files }, + }); + + const expectedEntries = files + .slice(0, 20) + .map(({ id, path }) => ({ data: id, file: { path, id } })); + + const expectedCursor = Cursor.create({ + actions: ['next', 'last'], + meta: { page: 1, count, pageSize: 20, pageCount: 76 }, + data: { files }, + }); + + const result = await giteaImplementation.traverseCursor(cursor, 'prev'); + + expect(result).toEqual({ + entries: expectedEntries, + cursor: expectedCursor, + }); + }); + + it('should handle last action', async () => { + const giteaImplementation = new GiteaImplementation(config); + giteaImplementation.api = mockAPI; + + const cursor = Cursor.create({ + actions: ['next', 'last'], + meta: { page: 1, count, pageSize: 20, pageCount: 76 }, + data: { files }, + }); + + const expectedEntries = files + .slice(1500) + .map(({ id, path }) => ({ data: id, file: { path, id } })); + + const expectedCursor = Cursor.create({ + actions: ['prev', 'first'], + meta: { page: 76, count, pageSize: 20, pageCount: 76 }, + data: { files }, + }); + + const result = await giteaImplementation.traverseCursor(cursor, 'last'); + + expect(result).toEqual({ + entries: expectedEntries, + cursor: expectedCursor, + }); + }); + + it('should handle first action', async () => { + const giteaImplementation = new GiteaImplementation(config); + giteaImplementation.api = mockAPI; + + const cursor = Cursor.create({ + actions: ['prev', 'first'], + meta: { page: 76, count, pageSize: 20, pageCount: 76 }, + data: { files }, + }); + + const expectedEntries = files + .slice(0, 20) + .map(({ id, path }) => ({ data: id, file: { path, id } })); + + const expectedCursor = Cursor.create({ + actions: ['next', 'last'], + meta: { page: 1, count, pageSize: 20, pageCount: 76 }, + data: { files }, + }); + + const result = await giteaImplementation.traverseCursor(cursor, 'first'); + + expect(result).toEqual({ + entries: expectedEntries, + cursor: expectedCursor, + }); + }); + }); +}); diff --git a/packages/core/src/backends/gitea/implementation.tsx b/packages/core/src/backends/gitea/implementation.tsx new file mode 100644 index 00000000..b32ea94f --- /dev/null +++ b/packages/core/src/backends/gitea/implementation.tsx @@ -0,0 +1,405 @@ +import { stripIndent } from 'common-tags'; +import trimStart from 'lodash/trimStart'; +import semaphore from 'semaphore'; + +import { + asyncLock, + basename, + blobToFileObj, + Cursor, + CURSOR_COMPATIBILITY_SYMBOL, + entriesByFiles, + entriesByFolder, + filterByExtension, + getBlobSHA, + getMediaAsBlob, + getMediaDisplayURL, + runWithLock, + unsentRequest, +} from '@staticcms/core/lib/util'; +import API, { API_NAME } from './API'; +import AuthenticationPage from './AuthenticationPage'; + +import type { + BackendClass, + BackendEntry, + Config, + Credentials, + DisplayURL, + ImplementationFile, + PersistOptions, + User, +} from '@staticcms/core/interface'; +import type { AsyncLock } from '@staticcms/core/lib/util'; +import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; +import type { Semaphore } from 'semaphore'; +import type { GiteaUser } from './types'; + +const MAX_CONCURRENT_DOWNLOADS = 10; + +type ApiFile = { id: string; type: string; name: string; path: string; size: number }; + +const { fetchWithTimeout: fetch } = unsentRequest; + +export default class Gitea implements BackendClass { + lock: AsyncLock; + api: API | null; + options: { + proxied: boolean; + API: API | null; + }; + originRepo: string; + repo?: string; + branch: string; + apiRoot: string; + mediaFolder?: string; + token: string | null; + _currentUserPromise?: Promise; + _userIsOriginMaintainerPromises?: { + [key: string]: Promise; + }; + _mediaDisplayURLSem?: Semaphore; + + constructor(config: Config, options = {}) { + this.options = { + proxied: false, + API: null, + ...options, + }; + + if ( + !this.options.proxied && + (config.backend.repo === null || config.backend.repo === undefined) + ) { + throw new Error('The Gitea backend needs a "repo" in the backend configuration.'); + } + + this.api = this.options.API || null; + this.repo = this.originRepo = config.backend.repo || ''; + this.branch = config.backend.branch?.trim() || 'main'; + this.apiRoot = config.backend.api_root || 'https://gitea.com/api/v1'; + this.token = ''; + this.mediaFolder = config.media_folder; + this.lock = asyncLock(); + } + + isGitBackend() { + return true; + } + + async status() { + const auth = + (await this.api + ?.user() + .then(user => !!user) + .catch(e => { + console.warn('Failed getting Gitea user', e); + return false; + })) || false; + + return { auth: { status: auth }, api: { status: true, statusPage: '' } }; + } + + authComponent() { + return AuthenticationPage; + } + + restoreUser(user: User) { + return this.authenticate(user); + } + + async currentUser({ token }: { token: string }) { + if (!this._currentUserPromise) { + this._currentUserPromise = fetch(`${this.apiRoot}/user`, { + headers: { + Authorization: `token ${token}`, + }, + }).then(res => res.json()); + } + return this._currentUserPromise; + } + + async userIsOriginMaintainer({ + username: usernameArg, + token, + }: { + username?: string; + token: string; + }) { + const username = usernameArg || (await this.currentUser({ token })).login; + this._userIsOriginMaintainerPromises = this._userIsOriginMaintainerPromises || {}; + if (!this._userIsOriginMaintainerPromises[username]) { + this._userIsOriginMaintainerPromises[username] = fetch( + `${this.apiRoot}/repos/${this.originRepo}/collaborators/${username}/permission`, + { + headers: { + Authorization: `token ${token}`, + }, + }, + ) + .then(res => res.json()) + .then(({ permission }) => permission === 'admin' || permission === 'write'); + } + return this._userIsOriginMaintainerPromises[username]; + } + + async authenticate(state: Credentials) { + this.token = state.token as string; + const apiCtor = API; + this.api = new apiCtor({ + token: this.token, + branch: this.branch, + repo: this.repo, + originRepo: this.originRepo, + apiRoot: this.apiRoot, + }); + const user = await this.api!.user(); + const isCollab = await this.api!.hasWriteAccess().catch(error => { + error.message = stripIndent` + Repo "${this.repo}" not found. + + Please ensure the repo information is spelled correctly. + + If the repo is private, make sure you're logged into a Gitea account with access. + + If your repo is under an organization, ensure the organization has granted access to Static + CMS. + `; + throw error; + }); + + // Unauthorized user + if (!isCollab) { + throw new Error('Your Gitea user account does not have access to this repo.'); + } + + // Authorized user + return { ...user, token: state.token as string }; + } + + logout() { + this.token = null; + if (this.api && this.api.reset && typeof this.api.reset === 'function') { + return this.api.reset(); + } + } + + getToken() { + return Promise.resolve(this.token); + } + + getCursorAndFiles = (files: ApiFile[], page: number) => { + const pageSize = 20; + const count = files.length; + const pageCount = Math.ceil(files.length / pageSize); + + const actions = [] as string[]; + if (page > 1) { + actions.push('prev'); + actions.push('first'); + } + if (page < pageCount) { + actions.push('next'); + actions.push('last'); + } + + const cursor = Cursor.create({ + actions, + meta: { page, count, pageSize, pageCount }, + data: { files }, + }); + const pageFiles = files.slice((page - 1) * pageSize, page * pageSize); + return { cursor, files: pageFiles }; + }; + + async entriesByFolder(folder: string, extension: string, depth: number) { + const repoURL = this.api!.originRepoURL; + + let cursor: Cursor; + + const listFiles = () => + this.api!.listFiles(folder, { + repoURL, + depth, + }).then(files => { + const filtered = files.filter(file => filterByExtension(file, extension)); + const result = this.getCursorAndFiles(filtered, 1); + cursor = result.cursor; + return result.files; + }); + + const readFile = (path: string, id: string | null | undefined) => + this.api!.readFile(path, id, { repoURL }) as Promise; + + 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) { + if (!mediaFolder) { + return []; + } + return this.api!.listFiles(mediaFolder).then(files => + files.map(({ id, name, size, path }) => { + // load media using getMediaDisplayURL to avoid token expiration with Gitlab raw content urls + // for private repositories + return { id, name, size, displayURL: { id, path }, path }; + }), + ); + } + + async getMediaFile(path: string) { + const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!)); + + const name = basename(path); + const fileObj = blobToFileObj(name, blob); + const url = URL.createObjectURL(fileObj); + const id = await getBlobSHA(blob); + + return { + id, + displayURL: url, + path, + name, + size: fileObj.size, + file: fileObj, + url, + }; + } + + getMediaDisplayURL(displayURL: DisplayURL) { + this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS); + return getMediaDisplayURL( + displayURL, + this.api!.readFile.bind(this.api!), + this._mediaDisplayURLSem, + ); + } + + persistEntry(entry: BackendEntry, options: PersistOptions) { + // persistEntry is a transactional operation + return runWithLock( + this.lock, + () => this.api!.persistFiles(entry.dataFiles, entry.assets, options), + 'Failed to acquire persist entry lock', + ); + } + + async persistMedia(mediaFile: AssetProxy, options: PersistOptions) { + try { + await this.api!.persistFiles([], [mediaFile], options); + const { sha, path, fileObj } = mediaFile as AssetProxy & { sha: string }; + const displayURL = URL.createObjectURL(fileObj as Blob); + return { + id: sha, + name: fileObj!.name, + size: fileObj!.size, + displayURL, + path: trimStart(path, '/'), + }; + } catch (error) { + console.error(error); + throw error; + } + } + + deleteFiles(paths: string[], commitMessage: string) { + return this.api!.deleteFiles(paths, commitMessage); + } + + async traverseCursor(cursor: Cursor, action: string) { + const meta = cursor.meta; + const files = (cursor.data?.files ?? []) as ApiFile[]; + + let result: { cursor: Cursor; files: ApiFile[] }; + switch (action) { + case 'first': { + result = this.getCursorAndFiles(files, 1); + break; + } + case 'last': { + result = this.getCursorAndFiles(files, (meta?.['pageCount'] as number) ?? 1); + break; + } + case 'next': { + result = this.getCursorAndFiles(files, (meta?.['page'] as number) + 1 ?? 1); + break; + } + case 'prev': { + result = this.getCursorAndFiles(files, (meta?.['page'] as number) - 1 ?? 1); + break; + } + default: { + result = this.getCursorAndFiles(files, 1); + break; + } + } + + const readFile = (path: string, id: string | null | undefined) => + this.api!.readFile(path, id, { repoURL: this.api!.originRepoURL }).catch( + () => '', + ) as Promise; + + const entries = await entriesByFiles( + result.files, + readFile, + this.api!.readFileMetadata.bind(this.api), + API_NAME, + ); + + return { + entries, + cursor: result.cursor, + }; + } +} diff --git a/packages/core/src/backends/gitea/index.ts b/packages/core/src/backends/gitea/index.ts new file mode 100644 index 00000000..0b8f05b9 --- /dev/null +++ b/packages/core/src/backends/gitea/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/core/src/backends/gitea/types.ts b/packages/core/src/backends/gitea/types.ts new file mode 100644 index 00000000..a64f8b37 --- /dev/null +++ b/packages/core/src/backends/gitea/types.ts @@ -0,0 +1,247 @@ +export type GiteaUser = { + active: boolean; + avatar_url: string; + created: string; + description: string; + email: string; + followers_count: number; + following_count: number; + full_name: string; + id: number; + is_admin: boolean; + language: string; + last_login: string; + location: string; + login: string; + login_name?: string; + prohibit_login: boolean; + restricted: boolean; + starred_repos_count: number; + visibility: string; + website: string; +}; + +export type GiteaTeam = { + can_create_org_repo: boolean; + description: string; + id: number; + includes_all_repositories: boolean; + name: string; + organization: GiteaOrganization; + permission: string; + units: Array; + 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 ReposListCommitsResponseItemCommitUser = { + date: string; + email: string; + name: string; +}; + +type ReposListCommitsResponseItemCommitMeta = { + created: string; + sha: string; + url: string; +}; + +type PayloadUser = { + email: string; + name: string; + username: string; +}; + +type PayloadCommitVerification = { + payload: string; + reason: string; + signature: string; + signer: PayloadUser; + verified: boolean; +}; + +type ReposListCommitsResponseItemCommit = { + author: ReposListCommitsResponseItemCommitUser; + committer: ReposListCommitsResponseItemCommitUser; + message: string; + tree: ReposListCommitsResponseItemCommitMeta; + url: string; + verification: PayloadCommitVerification; +}; + +type ReposGetResponsePermissions = { + admin: boolean; + pull: boolean; + push: boolean; +}; + +type ReposGetResponseExternalTracker = { + external_tracker_format: string; + external_tracker_regexp_pattern: string; + external_tracker_style: string; + external_tracker_url: string; +}; + +type ReposGetResponseExternalWiki = { + external_wiki_url: string; +}; + +type ReposGetResponseInternalTracker = { + allow_only_contributors_to_track_time: boolean; + enable_issue_dependencies: boolean; + enable_time_tracker: boolean; +}; + +type ReposGetResponseRepoTransfer = { + description: string; + doer: GiteaUser; + recipient: GiteaUser; + teams: Array; + enable_issue_dependencies: boolean; + enable_time_tracker: boolean; +}; + +export type ReposGetResponse = { + allow_merge_commits: boolean; + allow_rebase: boolean; + allow_rebase_explicit: boolean; + allow_rebase_update: boolean; + allow_squash_merge: boolean; + archived: boolean; + avatar_url: string; + clone_url: string; + created_at: string; + default_branch: string; + default_delete_branch_after_merge: boolean; + default_merge_style: boolean; + description: string; + empty: boolean; + external_tracker: ReposGetResponseExternalTracker; + external_wiki: ReposGetResponseExternalWiki; + fork: boolean; + forks_count: number; + full_name: string; + has_issues: boolean; + has_projects: boolean; + has_pull_requests: boolean; + has_wiki: boolean; + html_url: string; + id: number; + ignore_whitespace_conflicts: boolean; + internal: boolean; + internal_tracker: ReposGetResponseInternalTracker; + language: string; + languages_url: string; + mirror: boolean; + mirror_interval: string; + mirror_updated: string; + name: string; + open_issues_count: number; + open_pr_counter: number; + original_url: string; + owner: GiteaUser; + parent: null; + permissions: ReposGetResponsePermissions; + private: boolean; + release_counter: number; + repo_transfer: ReposGetResponseRepoTransfer; + size: number; + ssh_url: string; + stars_count: number; + template: boolean; + updated_at: string; + watchers_count: number; + website: string; +}; + +type ReposListCommitsResponseItemCommitAffectedFiles = { + filename: string; +}; + +type ReposListCommitsResponseItemCommitStats = { + additions: number; + deletions: number; + total: number; +}; + +type ReposListCommitsResponseItem = { + author: GiteaUser; + commit: ReposListCommitsResponseItemCommit; + committer: GiteaUser; + created: string; + files: Array; + 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; +}; + +export type GiteaIdentity = { + email: string; + name: string; +}; + +type FileLinksResponse = { + git: string; + html: string; + self: string; +}; + +export type ContentsResponse = { + _links: FileLinksResponse; + content?: string | null; + download_url: string; + encoding?: string | null; + git_url: string; + html_url: string; + last_commit_sha: string; + name: string; + path: string; + sha: string; + size: number; + submodule_git_url?: string | null; + target?: string | null; + type: string; + url: string; +}; diff --git a/packages/core/src/backends/index.tsx b/packages/core/src/backends/index.tsx index 2d788ebc..d7e7a5d6 100644 --- a/packages/core/src/backends/index.tsx +++ b/packages/core/src/backends/index.tsx @@ -2,5 +2,6 @@ export { BitbucketBackend } from './bitbucket'; export { GitGatewayBackend } from './git-gateway'; export { GitHubBackend } from './github'; export { GitLabBackend } from './gitlab'; +export { GiteaBackend } from './gitea'; export { ProxyBackend } from './proxy'; export { TestBackend } from './test'; diff --git a/packages/core/src/components/MediaLibrary/MediaLibrary.tsx b/packages/core/src/components/MediaLibrary/MediaLibrary.tsx index 19bafe0f..d9be425c 100644 --- a/packages/core/src/components/MediaLibrary/MediaLibrary.tsx +++ b/packages/core/src/components/MediaLibrary/MediaLibrary.tsx @@ -237,7 +237,7 @@ const MediaLibrary = ({ if ( !(await confirm({ title: 'mediaLibrary.mediaLibrary.onDeleteTitle', - body: 'mediaLibrary.mediaLibrary.onDelete', + body: 'mediaLibrary.mediaLibrary.onDeleteBody', color: 'error', })) ) { diff --git a/packages/core/src/components/UI/Icon/images/_index.tsx b/packages/core/src/components/UI/Icon/images/_index.tsx index 6f74a446..581d3f6b 100644 --- a/packages/core/src/components/UI/Icon/images/_index.tsx +++ b/packages/core/src/components/UI/Icon/images/_index.tsx @@ -1,12 +1,14 @@ import bitbucket from './bitbucket.svg'; import github from './github.svg'; import gitlab from './gitlab.svg'; +import gitea from './gitea.svg'; import staticCms from './static-cms-logo.svg'; const images = { bitbucket, github, gitlab, + gitea, 'static-cms': staticCms, }; diff --git a/packages/core/src/components/UI/Icon/images/gitea.svg b/packages/core/src/components/UI/Icon/images/gitea.svg new file mode 100644 index 00000000..797d894b --- /dev/null +++ b/packages/core/src/components/UI/Icon/images/gitea.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/core/src/extensions.ts b/packages/core/src/extensions.ts index 7498f813..1f281de2 100644 --- a/packages/core/src/extensions.ts +++ b/packages/core/src/extensions.ts @@ -3,6 +3,7 @@ import { GitGatewayBackend, GitHubBackend, GitLabBackend, + GiteaBackend, ProxyBackend, TestBackend, } from './backends'; @@ -32,6 +33,7 @@ export default function addExtensions() { registerBackend('git-gateway', GitGatewayBackend); registerBackend('github', GitHubBackend); registerBackend('gitlab', GitLabBackend); + registerBackend('gitea', GiteaBackend); registerBackend('bitbucket', BitbucketBackend); registerBackend('test-repo', TestBackend); registerBackend('proxy', ProxyBackend); diff --git a/packages/core/src/lib/auth/netlify-auth.ts b/packages/core/src/lib/auth/netlify-auth.ts index 7ea5be94..680b5011 100644 --- a/packages/core/src/lib/auth/netlify-auth.ts +++ b/packages/core/src/lib/auth/netlify-auth.ts @@ -27,6 +27,10 @@ const PROVIDERS = { width: 960, height: 600, }, + gitea: { + width: 960, + height: 600, + }, bitbucket: { width: 960, height: 500, diff --git a/packages/core/src/locales/bg/index.ts b/packages/core/src/locales/bg/index.ts index e488861d..268475b9 100644 --- a/packages/core/src/locales/bg/index.ts +++ b/packages/core/src/locales/bg/index.ts @@ -8,6 +8,7 @@ const bg: LocalePhrasesRoot = { loginWithBitbucket: 'Вход с Bitbucket', loginWithGitHub: 'Вход с GitHub', loginWithGitLab: 'Вход с GitLab', + loginWithGitea: 'Вход с Gitea', errors: { email: 'Въведете вашия имейл.', password: 'Въведете паролата.', diff --git a/packages/core/src/locales/ca/index.ts b/packages/core/src/locales/ca/index.ts index 71928711..cffd644e 100644 --- a/packages/core/src/locales/ca/index.ts +++ b/packages/core/src/locales/ca/index.ts @@ -8,6 +8,7 @@ const ca: LocalePhrasesRoot = { loginWithBitbucket: 'Iniciar sessió amb Bitbucket', loginWithGitHub: 'Iniciar sessió amb GitHub', loginWithGitLab: 'Iniciar sessió amb GitLab', + loginWithGitea: 'Iniciar sessió amb Gitea', errors: { email: 'Comprova que has escrit el teu email.', password: 'Si us plau escriu la teva contrasenya.', diff --git a/packages/core/src/locales/cs/index.ts b/packages/core/src/locales/cs/index.ts index b74a5083..7bd72ecf 100644 --- a/packages/core/src/locales/cs/index.ts +++ b/packages/core/src/locales/cs/index.ts @@ -8,6 +8,7 @@ const cs: LocalePhrasesRoot = { loginWithBitbucket: 'Přihlásit pomocí Bitbucket', loginWithGitHub: 'Přihlásit pomocí GitHub', loginWithGitLab: 'Přihlásit pomocí GitLab', + loginWithGitea: 'Přihlásit pomocí Gitea', errors: { email: 'Vyplňte e-mailovou adresu.', password: 'Vyplňte heslo.', diff --git a/packages/core/src/locales/da/index.ts b/packages/core/src/locales/da/index.ts index c60a14c9..ca8b908d 100644 --- a/packages/core/src/locales/da/index.ts +++ b/packages/core/src/locales/da/index.ts @@ -8,6 +8,7 @@ const da: LocalePhrasesRoot = { loginWithBitbucket: 'Log ind med Bitbucket', loginWithGitHub: 'Log ind med GitHub', loginWithGitLab: 'Log ind med GitLab', + loginWithGitea: 'Log ind med Gitea', errors: { email: 'Vær sikker på du har indtastet din e-mail.', password: 'Indtast dit kodeord.', diff --git a/packages/core/src/locales/de/index.ts b/packages/core/src/locales/de/index.ts index b5a88ea6..6120ded8 100644 --- a/packages/core/src/locales/de/index.ts +++ b/packages/core/src/locales/de/index.ts @@ -8,6 +8,7 @@ const de: LocalePhrasesRoot = { loginWithBitbucket: 'Mit Bitbucket einloggen', loginWithGitHub: 'Mit GitHub einloggen', loginWithGitLab: 'Mit GitLab einloggen', + loginWithGitea: 'Mit Gitea einloggen', errors: { email: 'Stellen Sie sicher, Ihre E-Mail-Adresse einzugeben.', password: 'Bitte geben Sie Ihr Passwort ein.', @@ -107,8 +108,10 @@ const de: LocalePhrasesRoot = { onPublishingWithUnsavedChangesBody: 'Es sind noch ungespeicherte Änderungen vorhanden. Bitte speicheren Sie vor dem Veröffentlichen.', onPublishingBody: 'Soll dieser Beitrag wirklich veröffentlicht werden?', + onDeleteWithUnsavedChangesTitle: 'Veröffentlichten Beitrag löschen?', onDeleteWithUnsavedChangesBody: 'Möchten Sie diesen veröffentlichten Beitrag, sowie Ihre nicht gespeicherten Änderungen löschen?', + onDeletePublishedEntryTitle: 'Veröffentlichten Beitrag löschen?', onDeletePublishedEntryBody: 'Soll dieser veröffentlichte Beitrag wirklich gelöscht werden?', loadingEntry: 'Beitrag laden...', confirmLoadBackupBody: diff --git a/packages/core/src/locales/en/index.ts b/packages/core/src/locales/en/index.ts index 0e043549..90cc7a81 100644 --- a/packages/core/src/locales/en/index.ts +++ b/packages/core/src/locales/en/index.ts @@ -8,6 +8,7 @@ const en: LocalePhrasesRoot = { loginWithBitbucket: 'Login with Bitbucket', loginWithGitHub: 'Login with GitHub', loginWithGitLab: 'Login with GitLab', + loginWithGitea: 'Login with Gitea', errors: { email: 'Make sure to enter your email.', password: 'Please enter your password.', diff --git a/packages/core/src/locales/es/index.ts b/packages/core/src/locales/es/index.ts index 3a9f056e..872285f0 100644 --- a/packages/core/src/locales/es/index.ts +++ b/packages/core/src/locales/es/index.ts @@ -8,6 +8,7 @@ const es: LocalePhrasesRoot = { loginWithBitbucket: 'Iniciar sesión con Bitbucket', loginWithGitHub: 'Iniciar sesión con GitHub', loginWithGitLab: 'Iniciar sesión con GitLab', + loginWithGitea: 'Iniciar sesión con Gitea', errors: { email: 'Asegúrate de introducir tu correo electrónico.', password: 'Por favor introduce tu contraseña.', diff --git a/packages/core/src/locales/fr/index.ts b/packages/core/src/locales/fr/index.ts index ea2d2c64..325689f2 100644 --- a/packages/core/src/locales/fr/index.ts +++ b/packages/core/src/locales/fr/index.ts @@ -8,6 +8,7 @@ const fr: LocalePhrasesRoot = { loginWithBitbucket: 'Se connecter avec Bitbucket', loginWithGitHub: 'Se connecter avec GitHub', loginWithGitLab: 'Se connecter avec GitLab', + loginWithGitea: 'Se connecter avec Gitea', errors: { email: "Assurez-vous d'avoir entré votre email.", password: 'Merci de saisir votre mot de passe.', diff --git a/packages/core/src/locales/gr/index.ts b/packages/core/src/locales/gr/index.ts index d9494455..c2ea5319 100644 --- a/packages/core/src/locales/gr/index.ts +++ b/packages/core/src/locales/gr/index.ts @@ -8,6 +8,7 @@ const gr: LocalePhrasesRoot = { loginWithBitbucket: 'Σύνδεση μέσω Bitbucket', loginWithGitHub: 'Σύνδεση μέσω GitHub', loginWithGitLab: 'Σύνδεση μέσω GitLab', + loginWithGitea: 'Σύνδεση μέσω Gitea', errors: { email: 'Βεβαιωθείτε ότι έχετε εισαγάγει το email σας.', password: 'Παρακαλώ εισάγετε τον κωδικό πρόσβασής σας.', diff --git a/packages/core/src/locales/he/index.ts b/packages/core/src/locales/he/index.ts index 8235e814..41ca025b 100644 --- a/packages/core/src/locales/he/index.ts +++ b/packages/core/src/locales/he/index.ts @@ -8,6 +8,7 @@ const he: LocalePhrasesRoot = { loginWithBitbucket: 'התחברות עם Bitbucket', loginWithGitHub: 'התחברות עם GitHub', loginWithGitLab: 'התחברות עם GitLab', + loginWithGitea: 'התחברות עם Gitea', errors: { email: 'נא לא לשכוח להקליד את כתובת המייל', password: 'נא להקליד את הסיסמה.', diff --git a/packages/core/src/locales/hr/index.ts b/packages/core/src/locales/hr/index.ts index 337a65c5..5b77a421 100644 --- a/packages/core/src/locales/hr/index.ts +++ b/packages/core/src/locales/hr/index.ts @@ -8,6 +8,7 @@ const hr: LocalePhrasesRoot = { loginWithBitbucket: 'Prijava sa Bitbucket računom', loginWithGitHub: 'Prijava sa GitHub računom', loginWithGitLab: 'Prijava sa GitLab računom', + loginWithGitea: 'Prijava sa Gitea računom', errors: { email: 'Unesite email.', password: 'Molimo unisite lozinku.', diff --git a/packages/core/src/locales/it/index.ts b/packages/core/src/locales/it/index.ts index 1f916e6e..63a366bf 100644 --- a/packages/core/src/locales/it/index.ts +++ b/packages/core/src/locales/it/index.ts @@ -8,6 +8,7 @@ const it: LocalePhrasesRoot = { loginWithBitbucket: 'Accedi con Bitbucket', loginWithGitHub: 'Accedi con GitHub', loginWithGitLab: 'Accedi con GitLab', + loginWithGitea: 'Accedi con Gitea', errors: { email: 'Assicurati di inserire la tua mail.', password: 'Inserisci la tua password.', diff --git a/packages/core/src/locales/ja/index.ts b/packages/core/src/locales/ja/index.ts index 87518a1c..5d71b6b9 100644 --- a/packages/core/src/locales/ja/index.ts +++ b/packages/core/src/locales/ja/index.ts @@ -8,6 +8,7 @@ const ja: LocalePhrasesRoot = { loginWithBitbucket: 'Bitbucket でログインする', loginWithGitHub: 'GitHub でログインする', loginWithGitLab: 'GitLab でログインする', + loginWithGitea: 'Gitea でログインする', errors: { email: 'メールアドレスを確認してください。', password: 'パスワードを入力してください。', diff --git a/packages/core/src/locales/ko/index.ts b/packages/core/src/locales/ko/index.ts index 9a3df24e..1c121efe 100644 --- a/packages/core/src/locales/ko/index.ts +++ b/packages/core/src/locales/ko/index.ts @@ -8,6 +8,7 @@ const ko: LocalePhrasesRoot = { loginWithBitbucket: 'Bitbucket 으로 로그인', loginWithGitHub: 'GitHub 로 로그인', loginWithGitLab: 'GitLab 으로 로그인', + loginWithGitea: 'Gitea 으로 로그인', errors: { email: '반드시 이메일을 입력해 주세요.', password: '암호를 입력해 주세요.', diff --git a/packages/core/src/locales/lt/index.ts b/packages/core/src/locales/lt/index.ts index 46e2c41b..7c3effc5 100644 --- a/packages/core/src/locales/lt/index.ts +++ b/packages/core/src/locales/lt/index.ts @@ -8,6 +8,7 @@ const lt: LocalePhrasesRoot = { loginWithBitbucket: 'Prisijungti su Bitbucket', loginWithGitHub: 'Prisijungti su GitHub', loginWithGitLab: 'Prisijungti su GitLab', + loginWithGitea: 'Prisijungti su Gitea', errors: { email: 'Įveskite savo elektroninį paštą.', password: 'Įveskite savo slaptažodį.', diff --git a/packages/core/src/locales/nb_no/index.ts b/packages/core/src/locales/nb_no/index.ts index d675dae9..d9e41e53 100644 --- a/packages/core/src/locales/nb_no/index.ts +++ b/packages/core/src/locales/nb_no/index.ts @@ -8,6 +8,7 @@ const nb_no: LocalePhrasesRoot = { loginWithBitbucket: 'Logg på med Bitbucket', loginWithGitHub: 'Logg på med GitHub', loginWithGitLab: 'Logg på med GitLab', + loginWithGitea: 'Logg på med Gitea', errors: { email: 'Du må skrive inn e-posten din.', password: 'Du må skrive inn passordet ditt.', diff --git a/packages/core/src/locales/nl/index.ts b/packages/core/src/locales/nl/index.ts index 14309f5d..c3db02e5 100644 --- a/packages/core/src/locales/nl/index.ts +++ b/packages/core/src/locales/nl/index.ts @@ -8,6 +8,7 @@ const nl: LocalePhrasesRoot = { loginWithBitbucket: 'Inloggen met Bitbucket', loginWithGitHub: 'Inloggen met GitHub', loginWithGitLab: 'Inloggen met GitLab', + loginWithGitea: 'Inloggen met Gitea', errors: { email: 'Voer uw email in.', password: 'Voer uw wachtwoord in.', diff --git a/packages/core/src/locales/nn_no/index.ts b/packages/core/src/locales/nn_no/index.ts index 49e622fe..f090e5ea 100644 --- a/packages/core/src/locales/nn_no/index.ts +++ b/packages/core/src/locales/nn_no/index.ts @@ -8,6 +8,7 @@ const nn_no: LocalePhrasesRoot = { loginWithBitbucket: 'Logg på med Bitbucket', loginWithGitHub: 'Logg på med GitHub', loginWithGitLab: 'Logg på med GitLab', + loginWithGitea: 'Logg på med Gitea', errors: { email: 'Du må skriva inn e-posten din.', password: 'Du må skriva inn passordet ditt.', diff --git a/packages/core/src/locales/pl/index.ts b/packages/core/src/locales/pl/index.ts index 91a7c6c7..a7721050 100644 --- a/packages/core/src/locales/pl/index.ts +++ b/packages/core/src/locales/pl/index.ts @@ -8,6 +8,7 @@ const pl: LocalePhrasesRoot = { loginWithBitbucket: 'Zaloguj przez Bitbucket', loginWithGitHub: 'Zaloguj przez GitHub', loginWithGitLab: 'Zaloguj przez GitLab', + loginWithGitea: 'Zaloguj przez Gitea', errors: { email: 'Wprowadź swój adres email', password: 'Wprowadź swoje hasło', diff --git a/packages/core/src/locales/pt/index.ts b/packages/core/src/locales/pt/index.ts index f16e43f9..17209548 100644 --- a/packages/core/src/locales/pt/index.ts +++ b/packages/core/src/locales/pt/index.ts @@ -8,6 +8,7 @@ const pt: LocalePhrasesRoot = { loginWithBitbucket: 'Entrar com o Bitbucket', loginWithGitHub: 'Entrar com o GitHub', loginWithGitLab: 'Entrar com o GitLab', + loginWithGitea: 'Entrar com o Gitea', errors: { email: 'Certifique-se de inserir seu e-mail.', password: 'Por favor, insira sua senha.', diff --git a/packages/core/src/locales/ro/index.ts b/packages/core/src/locales/ro/index.ts index 449b3647..56415dee 100644 --- a/packages/core/src/locales/ro/index.ts +++ b/packages/core/src/locales/ro/index.ts @@ -8,6 +8,7 @@ const ro: LocalePhrasesRoot = { loginWithBitbucket: 'Autentifică-te cu Bitbucket', loginWithGitHub: 'Autentifică-te cu GitHub', loginWithGitLab: 'Autentifică-te cu GitLab', + loginWithGitea: 'Autentifică-te cu Gitea', errors: { email: 'Asigură-te că ai introdus email-ul.', password: 'Te rugăm introdu parola.', diff --git a/packages/core/src/locales/ru/index.ts b/packages/core/src/locales/ru/index.ts index 4c801d69..e9b64f24 100644 --- a/packages/core/src/locales/ru/index.ts +++ b/packages/core/src/locales/ru/index.ts @@ -8,6 +8,7 @@ const ru: LocalePhrasesRoot = { loginWithBitbucket: 'Войти через Bitbucket', loginWithGitHub: 'Войти через GitHub', loginWithGitLab: 'Войти через GitLab', + loginWithGitea: 'Войти через Gitea', errors: { email: 'Введите ваш email.', password: 'Введите пароль.', diff --git a/packages/core/src/locales/sv/index.ts b/packages/core/src/locales/sv/index.ts index 86251d73..037e1d2f 100644 --- a/packages/core/src/locales/sv/index.ts +++ b/packages/core/src/locales/sv/index.ts @@ -8,6 +8,7 @@ const sv: LocalePhrasesRoot = { loginWithBitbucket: 'Logga in med Bitbucket', loginWithGitHub: 'Logga in med GitHub', loginWithGitLab: 'Logga in med GitLab', + loginWithGitea: 'Logga in med Gitea', errors: { email: 'Fyll i din epostadress.', password: 'Vänligen skriv ditt lösenord.', diff --git a/packages/core/src/locales/th/index.ts b/packages/core/src/locales/th/index.ts index 1beb4b19..86ed204c 100644 --- a/packages/core/src/locales/th/index.ts +++ b/packages/core/src/locales/th/index.ts @@ -8,6 +8,7 @@ const th: LocalePhrasesRoot = { loginWithBitbucket: 'เข้าสู่ระบบด้วย Bitbucket', loginWithGitHub: 'เข้าสู่ระบบด้วย GitHub', loginWithGitLab: 'เข้าสู่ระบบด้วย GitLab', + loginWithGitea: 'เข้าสู่ระบบด้วย Gitea', errors: { email: 'ตรวจสอบให้แน่ใจว่าได้ใส่อีเมลล์แล้ว', password: 'โปรดใส่รหัสผ่านของคุณ', diff --git a/packages/core/src/locales/tr/index.ts b/packages/core/src/locales/tr/index.ts index 147f44f3..f75f3e3f 100644 --- a/packages/core/src/locales/tr/index.ts +++ b/packages/core/src/locales/tr/index.ts @@ -8,6 +8,7 @@ const tr: LocalePhrasesRoot = { loginWithBitbucket: 'Bitbucket ile Giriş', loginWithGitHub: 'GitHub ile Giriş', loginWithGitLab: 'GitLab ile Giriş', + loginWithGitea: 'Gitea ile Giriş', errors: { email: 'E-postanızı girdiğinizden emin olun.', password: 'Lütfen şifrenizi girin.', diff --git a/packages/core/src/locales/vi/index.ts b/packages/core/src/locales/vi/index.ts index e928e8f3..1d2c7db7 100644 --- a/packages/core/src/locales/vi/index.ts +++ b/packages/core/src/locales/vi/index.ts @@ -8,6 +8,7 @@ const vi: LocalePhrasesRoot = { loginWithBitbucket: 'Đăng nhập bằng Bitbucket', loginWithGitHub: 'Đăng nhập bằng GitHub', loginWithGitLab: 'Đăng nhập bằng GitLab', + loginWithGitea: 'Đăng nhập bằng Gitea', errors: { email: 'Hãy nhập email của bạn.', password: 'Hãy nhập mật khẩu của bạn.', diff --git a/packages/core/src/locales/zh_Hans/index.ts b/packages/core/src/locales/zh_Hans/index.ts index d3189e99..9bb5e689 100644 --- a/packages/core/src/locales/zh_Hans/index.ts +++ b/packages/core/src/locales/zh_Hans/index.ts @@ -8,6 +8,7 @@ const zh_Hans: LocalePhrasesRoot = { loginWithBitbucket: '使用 Bitbucket 登录', loginWithGitHub: '使用 GitHub 登录', loginWithGitLab: '使用 GitLab 登录', + loginWithGitea: '使用 Gitea 登录', errors: { email: '请输入电子邮箱', password: '请输入密码', diff --git a/packages/core/src/locales/zh_Hant/index.ts b/packages/core/src/locales/zh_Hant/index.ts index a34ee437..6b9ba55a 100644 --- a/packages/core/src/locales/zh_Hant/index.ts +++ b/packages/core/src/locales/zh_Hant/index.ts @@ -8,6 +8,7 @@ const zh_Hant: LocalePhrasesRoot = { loginWithBitbucket: '使用你的 Bitbucket 帳號來進行登入', loginWithGitHub: '使用你的 GitHub 帳號來進行登入', loginWithGitLab: '使用你的 GitLab 帳號來進行登入', + loginWithGitea: '使用你的 Gitea 帳號來進行登入', errors: { email: '請確認你已經輸入你的電子郵件。', password: '請輸入你的密碼。', diff --git a/packages/docs/content/docs/backends-overview.mdx b/packages/docs/content/docs/backends-overview.mdx index d64f8345..55a6d5a0 100644 --- a/packages/docs/content/docs/backends-overview.mdx +++ b/packages/docs/content/docs/backends-overview.mdx @@ -10,15 +10,15 @@ A backend is JavaScript code that allows Static CMS to communicate with a servic Individual backends provide their own configuration documentation, but there are some configuration options that are common to multiple backends. A full reference is below. Note that these are properties of the `backend` field, and should be nested under that field. -| Name | Type | Default | Description | -| ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| name | 'git-gateway'
\| 'github'
\| 'gitlab'
\| 'bitbucket'
\| 'test-repo'
\| 'proxy' | | The backend git provider | -| repo | string | | Required for `github`, `gitlab`, and `bitbucket` backends. Ignored by `git-gateway`. Follows the pattern `[org-or-username]/[repo-name]` | -| branch | string | `main` | _Optional_. The branch where published content is stored. All CMS commits and PRs are made to this branch | -| api_root | string | GitHub
`https://api.github.com`

GitLab
`https://gitlab.com/api/v4`

Bitbucket
`https://api.bitbucket.org/2.0` | _Optional_. The API endpoint. Only necessary in certain cases, like with GitHub Enterprise or self-hosted GitLab | -| site_domain | string | `location.hostname`

On `localhost`
`cms.netlify.com` | _Optional_. Sets the `site_id` query param sent to the API endpoint. Non-Netlify auth setups will often need to set this for local development to work properly | -| base_url | string | GitHub or Bitbucket
`https://api.netlify.com`

GitLab
`https://gitlab.com` | _Optional_. OAuth client hostname (just the base domain, no path). **Required** when using an external OAuth server or self-hosted GitLab | -| auth_endpoint | string | GitHub or Bitbucket
`auth`

GitLab
`oauth/authorize` | _Optional_. Path to append to `base_url` for authentication requests. | +| Name | Type | Default | Description | +| ------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| name | 'git-gateway'
\| 'github'
\| 'gitlab'
\| 'bitbucket'
\| 'gitea'
\|'test-repo'
\| 'proxy' | | The backend git provider | +| repo | string | | Required for `github`, `gitlab`, `gitea` and `bitbucket` backends. Ignored by `git-gateway`. Follows the pattern `[org-or-username]/[repo-name]` | +| branch | string | `main` | _Optional_. The branch where published content is stored. All CMS commits and PRs are made to this branch | +| api_root | string | GitHub
`https://api.github.com`

GitLab
`https://gitlab.com/api/v4`

Bitbucket
`https://api.bitbucket.org/2.0`

Gitea
`https://try.gitea.io/api/v1` | _Optional_. The API endpoint. Only necessary in certain cases, like with GitHub Enterprise or self-hosted GitLab | +| site_domain | string | `location.hostname`

On `localhost`
`cms.netlify.com` | _Optional_. Sets the `site_id` query param sent to the API endpoint. Non-Netlify auth setups will often need to set this for local development to work properly | +| base_url | string | GitHub or Bitbucket
`https://api.netlify.com`

GitLab
`https://gitlab.com` | _Optional_. OAuth client hostname (just the base domain, no path). **Required** when using an external OAuth server or self-hosted GitLab/Gitea | +| auth_endpoint | string | GitHub or Bitbucket
`auth`

GitLab
`oauth/authorize` | _Optional_. Path to append to `base_url` for authentication requests. | ## Creating a New Backend diff --git a/packages/docs/content/docs/gitea-backend.mdx b/packages/docs/content/docs/gitea-backend.mdx new file mode 100644 index 00000000..4a70dd08 --- /dev/null +++ b/packages/docs/content/docs/gitea-backend.mdx @@ -0,0 +1,48 @@ +--- +title: Gitea +group: Backends +weight: 45 +beta: true +--- + +- **Name**: `gitea` + +For repositories stored on Gitea, the `gitea` backend allows CMS users to log in directly with their Gitea account. Note that all users must have push access to your content repository for this to work. + +## Authentication + +Because Gitea requires a server for authentication and Netlify doesn't support Gitea, a custom OAuth provider needs to be used for basic Gitea authentication. + +To enable basic Gitea authentication: + +1. Setup an own OAuth provider, for example with [scm-oauth](https://github.com/denyskon/scm-oauth-provider). +2. Add the following lines to your Static CMS `config` file: + + +```yaml +backend: + name: gitea + repo: owner-name/repo-name # Path to your Gitea repository + base_url: https://oauth.example.com # URL of your OAuth provider + api_root: https://gitea.example.com/api/v1 # API url of your Gitea instance + # optional, defaults to main + # branch: main + +``` + +```js +backend: { + name: 'gitea', + repo: 'owner-name/repo-name', // Path to your Gitea repository + base_url: 'https://oauth.example.com', // URL of your OAuth provider + api_root: 'https://gitea.example.com/api/v1' // API url of your Gitea instance + // optional, defaults to main + // branch: 'main' +}, +``` + + + +## Git Large File Storage (LFS) + +Please note that the Gitea backend **does not** support [git-lfs](https://git-lfs.github.com/).