Files
Daniel Lautzenheiser 79877fcd1f feat: events (#717)
2023-04-19 23:49:53 -04:00

562 lines
16 KiB
TypeScript

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 trimStart from 'lodash/trimStart';
import { dirname } from 'path';
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 {
GitCreateCommitResponse,
GitCreateRefResponse,
GitCreateTreeParamsTree,
GitCreateTreeResponse,
GitGetBlobResponse,
GitGetTreeResponse,
GitHubAuthor,
GitHubCommitter,
GitHubUser,
GitUpdateRefResponse,
ReposGetBranchResponse,
ReposGetResponse,
ReposListCommitsResponse,
} from './types';
export const API_NAME = 'GitHub';
export interface Config {
apiRoot?: string;
token?: string;
branch?: string;
repo?: string;
originRepo?: string;
}
type Override<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
type TreeEntry = Override<GitCreateTreeParamsTree, { sha: string | null }>;
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 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<GitHubUser>;
_metadataSemaphore?: Semaphore;
commitAuthor?: {};
constructor(config: Config) {
this.apiRoot = config.apiRoot || 'https://api.github.com';
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<{ name: string; login: string }> {
if (!this._userPromise) {
this._userPromise = this.getUser();
}
return this._userPromise;
}
getUser() {
return this.request('/user') as Promise<GitHubUser>;
}
async hasWriteAccess() {
try {
const result: ReposGetResponse = await this.request(this.repoURL);
// update config repoOwner to avoid case sensitivity issues with GitHub
this.repoOwner = result.owner.login;
return result.permissions.push;
} catch (error) {
console.error('Problem fetching repo data from GitHub');
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);
}
parseJsonResponse(response: Response) {
return response.json().then(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 },
},
);
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}:${folder}`,
{
// GitHub 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) &&
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 uploadPromises = files.map(file => this.uploadBlob(file));
await Promise.all(uploadPromises);
return (
this.getDefaultBranch()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.then(branchData => this.updateTree(branchData.commit.sha, files as any))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then(response => this.patchBranch(this.branch, response.sha))
);
}
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 branchData = await this.getDefaultBranch();
const files = paths.map(path => ({ path, sha: null }));
const changeTree = await this.updateTree(branchData.commit.sha, files);
const commit = await this.commit(message, changeTree);
await this.patchBranch(this.branch, commit.sha);
}
async createRef(type: string, name: string, sha: string) {
const result: GitCreateRefResponse = await this.request(`${this.repoURL}/git/refs`, {
method: 'POST',
body: JSON.stringify({ ref: `refs/${type}/${name}`, sha }),
});
return result;
}
async patchRef(type: string, name: string, sha: string) {
const result: GitUpdateRefResponse = await this.request(
`${this.repoURL}/git/refs/${type}/${encodeURIComponent(name)}`,
{
method: 'PATCH',
body: JSON.stringify({ sha }),
},
);
return result;
}
deleteRef(type: string, name: string) {
return this.request(`${this.repoURL}/git/refs/${type}/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
}
async getDefaultBranch() {
const result: ReposGetBranchResponse = await this.request(
`${this.originRepoURL}/branches/${encodeURIComponent(this.branch)}`,
);
return result;
}
patchBranch(branchName: string, sha: string) {
return this.patchRef('heads', branchName, sha);
}
async getHeadReference(head: string) {
return `${this.repoOwner}:${head}`;
}
toBase64(str: string) {
return Promise.resolve(Base64.encode(str));
}
async uploadBlob(item: { raw?: string; sha?: string; toBase64?: () => Promise<string> }) {
const contentBase64 = await result(
item,
'toBase64',
partial(this.toBase64, item.raw as string),
);
const response = await this.request(`${this.repoURL}/git/blobs`, {
method: 'POST',
body: JSON.stringify({
content: contentBase64,
encoding: 'base64',
}),
});
item.sha = response.sha;
return item;
}
async updateTree(
baseSha: string,
files: { path: string; sha: string | null; newPath?: string }[],
branch = this.branch,
) {
const toMove: { from: string; to: string; sha: string }[] = [];
const tree = files.reduce((acc, file) => {
const entry = {
path: trimStart(file.path, '/'),
mode: '100644',
type: 'blob',
sha: file.sha,
} as TreeEntry;
if (file.newPath) {
toMove.push({ from: file.path, to: file.newPath, sha: file.sha as string });
} else {
acc.push(entry);
}
return acc;
}, [] as TreeEntry[]);
for (const { from, to, sha } of toMove) {
const sourceDir = dirname(from);
const destDir = dirname(to);
const files = await this.listFiles(sourceDir, { branch, depth: 100 });
for (const file of files) {
// delete current path
tree.push({
path: file.path,
mode: '100644',
type: 'blob',
sha: null,
});
// create in new path
tree.push({
path: file.path.replace(sourceDir, destDir),
mode: '100644',
type: 'blob',
sha: file.path === from ? sha : file.id,
});
}
}
const newTree = await this.createTree(baseSha, tree);
return { ...newTree, parentSha: baseSha };
}
async createTree(baseSha: string, tree: TreeEntry[]) {
const result: GitCreateTreeResponse = await this.request(`${this.repoURL}/git/trees`, {
method: 'POST',
body: JSON.stringify({ base_tree: baseSha, tree }),
});
return result;
}
commit(message: string, changeTree: { parentSha?: string; sha: string }) {
const parents = changeTree.parentSha ? [changeTree.parentSha] : [];
return this.createCommit(message, changeTree.sha, parents);
}
async createCommit(
message: string,
treeSha: string,
parents: string[],
author?: GitHubAuthor,
committer?: GitHubCommitter,
) {
const result: GitCreateCommitResponse = await this.request(`${this.repoURL}/git/commits`, {
method: 'POST',
body: JSON.stringify({ message, tree: treeSha, parents, author, committer }),
});
return result;
}
}