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, 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); for (const file of files) { const item: { raw?: string; sha?: string; toBase64?: () => Promise } = file; const contentBase64 = await result( item, 'toBase64', partial(this.toBase64, item.raw as string), ); try { const oldSha = await this.getFileSha(file.path); await this.updateBlob(contentBase64, file, options, oldSha!); } catch { await this.createBlob(contentBase64, file, options); } } } async updateBlob( contentBase64: string, file: AssetProxy | DataFile, options: PersistOptions, oldSha: string, ) { await this.request(`${this.repoURL}/contents/${file.path}`, { method: 'PUT', body: JSON.stringify({ branch: this.branch, content: contentBase64, message: options.commitMessage, sha: oldSha, signoff: false, }), }); } async createBlob(contentBase64: string, file: AssetProxy | DataFile, options: PersistOptions) { await this.request(`${this.repoURL}/contents/${file.path}`, { method: 'POST', body: JSON.stringify({ branch: this.branch, content: contentBase64, message: options.commitMessage, 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 { throw new APIError('Not Found', 404, API_NAME); } } async deleteFiles(paths: string[], message: string) { for (const file of paths) { 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)); } }