add-cms
This commit is contained in:
468
source/admin/packages/decap-cms-backend-gitea/src/API.ts
Normal file
468
source/admin/packages/decap-cms-backend-gitea/src/API.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
import { Base64 } from 'js-base64';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import trim from 'lodash/trim';
|
||||
import result from 'lodash/result';
|
||||
import partial from 'lodash/partial';
|
||||
import last from 'lodash/last';
|
||||
import initial from 'lodash/initial';
|
||||
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, Param | Record<string, Param> | 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<GiteaUser>;
|
||||
_metadataSemaphore?: Semaphore;
|
||||
|
||||
commitAuthor?: {};
|
||||
|
||||
constructor(config: Config) {
|
||||
this.apiRoot = config.apiRoot || 'https://try.gitea.io/api/v1';
|
||||
this.token = config.token || '';
|
||||
this.branch = config.branch || '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<GiteaUser>;
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
...headers,
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
baseHeader.Authorization = `token ${this.token}`;
|
||||
return Promise.resolve(baseHeader);
|
||||
}
|
||||
|
||||
return Promise.resolve(baseHeader);
|
||||
}
|
||||
|
||||
async parseJsonResponse(response: Response) {
|
||||
const json = await response.json();
|
||||
if (!response.ok) {
|
||||
return Promise.reject(json);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
urlFor(path: string, options: Options) {
|
||||
const params = [];
|
||||
if (options.params) {
|
||||
for (const key in options.params) {
|
||||
params.push(`${key}=${encodeURIComponent(options.params[key] as string)}`);
|
||||
}
|
||||
}
|
||||
if (params.length) {
|
||||
path += `?${params.join('&')}`;
|
||||
}
|
||||
return this.apiRoot + path;
|
||||
}
|
||||
|
||||
parseResponse(response: Response) {
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (contentType && contentType.match(/json/)) {
|
||||
return this.parseJsonResponse(response);
|
||||
}
|
||||
const textPromise = response.text().then(text => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(text);
|
||||
}
|
||||
return text;
|
||||
});
|
||||
return textPromise;
|
||||
}
|
||||
|
||||
handleRequestError(error: FetchError, responseStatus: number) {
|
||||
throw new APIError(error.message, responseStatus, API_NAME);
|
||||
}
|
||||
|
||||
buildRequest(req: ApiRequest) {
|
||||
return req;
|
||||
}
|
||||
|
||||
async request(
|
||||
path: string,
|
||||
options: Options = {},
|
||||
parser = (response: Response) => this.parseResponse(response),
|
||||
) {
|
||||
options = { cache: 'no-cache', ...options };
|
||||
const headers = await this.requestHeaders(options.headers || {});
|
||||
const url = this.urlFor(path, options);
|
||||
let responseStatus = 500;
|
||||
|
||||
try {
|
||||
const req = unsentRequest.fromFetchArguments(url, {
|
||||
...options,
|
||||
headers,
|
||||
}) as unknown as ApiRequest;
|
||||
const response = await requestWithBackoff(this, req);
|
||||
responseStatus = response.status;
|
||||
const parsedResponse = await parser(response);
|
||||
return parsedResponse;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
return this.handleRequestError(error, responseStatus);
|
||||
}
|
||||
}
|
||||
|
||||
nextUrlProcessor() {
|
||||
return (url: string) => url;
|
||||
}
|
||||
|
||||
async requestAllPages<T>(url: string, options: Options = {}) {
|
||||
options = { cache: 'no-cache', ...options };
|
||||
const headers = await this.requestHeaders(options.headers || {});
|
||||
const processedURL = this.urlFor(url, options);
|
||||
const allResponses = await getAllResponses(
|
||||
processedURL,
|
||||
{ ...options, headers },
|
||||
'next',
|
||||
this.nextUrlProcessor(),
|
||||
);
|
||||
const pages: T[][] = await Promise.all(
|
||||
allResponses.map((res: Response) => this.parseResponse(res)),
|
||||
);
|
||||
return ([] as T[]).concat(...pages);
|
||||
}
|
||||
|
||||
generateContentKey(collectionName: string, slug: string) {
|
||||
return generateContentKey(collectionName, slug);
|
||||
}
|
||||
|
||||
parseContentKey(contentKey: string) {
|
||||
return parseContentKey(contentKey);
|
||||
}
|
||||
|
||||
async readFile(
|
||||
path: string,
|
||||
sha?: string | null,
|
||||
{
|
||||
branch = this.branch,
|
||||
repoURL = this.repoURL,
|
||||
parseText = true,
|
||||
}: {
|
||||
branch?: string;
|
||||
repoURL?: string;
|
||||
parseText?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!sha) {
|
||||
sha = await this.getFileSha(path, { repoURL, branch });
|
||||
}
|
||||
const content = await this.fetchBlobContent({ sha: sha as string, repoURL, parseText });
|
||||
return content;
|
||||
}
|
||||
|
||||
async readFileMetadata(path: string, sha: string | null | undefined) {
|
||||
const fetchFileMetadata = async () => {
|
||||
try {
|
||||
const result: ReposListCommitsResponse = await this.request(
|
||||
`${this.originRepoURL}/commits`,
|
||||
{
|
||||
params: { path, sha: this.branch, 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 (<rev>:<path>) to get a blob from a tree without having to recurse
|
||||
* through the tree.
|
||||
*/
|
||||
|
||||
const pathArray = path.split('/');
|
||||
const filename = last(pathArray);
|
||||
const directory = initial(pathArray).join('/');
|
||||
const fileDataPath = encodeURIComponent(directory);
|
||||
const fileDataURL = `${repoURL}/git/trees/${branch}:${fileDataPath}`;
|
||||
|
||||
const result: GitGetTreeResponse = await this.request(fileDataURL);
|
||||
const file = result.tree.find(file => file.path === filename);
|
||||
if (file) {
|
||||
return file.sha;
|
||||
} else {
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user