diff --git a/src/platforms/Gitea/API.ts b/src/platforms/Gitea/API.ts new file mode 100644 index 0000000..2a7a849 --- /dev/null +++ b/src/platforms/Gitea/API.ts @@ -0,0 +1,107 @@ +import { raiseError } from 'analytics' +import { errors } from 'platforms' + +function isEmptyProject(content: any /* safe any */) { + return content?.['message'] === 'Git Repository is empty.' +} + +function isBlockedProject(content: any /* safe any */) { + return content?.['message'] === 'Repository access blocked' +} + +async function request( + url: string, + { + accessToken, + }: { + accessToken?: string + } = {}, +) { + const headers = {} as HeadersInit & { + Authorization?: string + } + if (accessToken) { + headers.Authorization = `token ${accessToken}` + } + + let res: Response + try { + res = await fetch(url, { headers }) + } catch (err) { + throw new Error(errors.CONNECTION_BLOCKED) + } + + const contentType = res.headers.get('Content-Type') || res.headers.get('content-type') + if (!contentType) { + throw new Error(`Response has no content type`) + } else if (!contentType.includes('application/json')) { + throw new Error(`Response content type is ${contentType}`) + } + + // About res.ok: + // True if res.status between 200~299 + // Ref: https://developer.mozilla.org/en-US/docs/Web/API/Response/ok + if (res.ok) { + return res.json() + } else { + if (res.status === 404 || res.status === 401) throw new Error(errors.NOT_FOUND) + else if (res.status === 500) throw new Error(errors.SERVER_FAULT) + else { + const content = await res.json() + if (isEmptyProject(content)) throw new Error(errors.EMPTY_PROJECT) + if (isBlockedProject(content)) throw new Error(errors.BLOCKED_PROJECT) + // Unknown type of error, report it! + raiseError(new Error(res.statusText)) + throw new Error(content && content.message) + } + } +} + +export const API_ENDPOINT = `${window.location.host}/api/v1` + +export async function getRepoMeta( + userName: string, + repoName: string, + accessToken?: string, +): Promise { + const url = `https://${API_ENDPOINT}/repos/${userName}/${repoName}` + return await request(url, { accessToken }) +} + +export async function getTreeData( + userName: string, + repoName: string, + branchName: string, + recursive?: boolean, + accessToken?: string, +): Promise { + const search = new URLSearchParams() + if (recursive) search.set('recursive', '1') + const url = + `https://${API_ENDPOINT}/repos/${userName}/${repoName}/git/trees/${branchName}?` + search + return await request(url, { accessToken }) +} + +export async function getBlobData( + userName: string, + repoName: string, + sha: string, + accessToken?: string, +): Promise { + const url = `https://${API_ENDPOINT}/repos/${userName}/${repoName}/git/blobs/${sha}` + return await request(url, { accessToken }) +} + +export async function OAuth(code: string): Promise { + const endpoint = `https://gitako.now.sh/oauth/gitea?` + const res = await fetch(endpoint + new URLSearchParams({ code }).toString(), { + method: 'post', + }) + + if (res.ok) { + const body = await res.json() + const accessToken = body?.accessToken + if (typeof accessToken === 'string') return accessToken + } + return null +} diff --git a/src/platforms/Gitea/DOMHelper.ts b/src/platforms/Gitea/DOMHelper.ts new file mode 100644 index 0000000..05d35c4 --- /dev/null +++ b/src/platforms/Gitea/DOMHelper.ts @@ -0,0 +1,24 @@ +import { raiseError } from 'analytics' +import { $ } from 'utils/DOMHelper' + +export function isInRepoPage() { + const repoHeaderSelector = '.repo-header' + return Boolean($(repoHeaderSelector)) +} + +export function isInCodePage() { + const branchListSelector = '.reference' + return Boolean($(branchListSelector)) +} + +export function getCurrentBranch() { + const branchListSelector = '.reference' + const branchButtonElement: HTMLElement = $(branchListSelector) + const branchNameElement = branchButtonElement.querySelector('.selected') + if (branchNameElement) { + return branchNameElement.textContent; + } + + raiseError(new Error('cannot get current branch')) +} + diff --git a/src/platforms/Gitea/Request.d.ts b/src/platforms/Gitea/Request.d.ts new file mode 100644 index 0000000..1132de1 --- /dev/null +++ b/src/platforms/Gitea/Request.d.ts @@ -0,0 +1,42 @@ +declare namespace GiteaAPI { + type TreeItem = { + path: string + mode: string + sha: string + size: number + url: string + type: 'blob' | 'commit' | 'tree' + } + + type TreeData = { + sha: string + truncated: boolean + tree: TreeItem[] + url: string + } + + type MetaData = { + name: string + default_branch: string + html_url: string + owner: { + login: string + html_url: string + } + } + + type BlobData = { + encoding: 'base64' | string + sha: string + content?: string + size: number + url: string + } + + type OAuth = { + access_token?: string + scope?: string + token_type?: string + error_description?: string + } +} diff --git a/src/platforms/Gitea/URLHelper.ts b/src/platforms/Gitea/URLHelper.ts new file mode 100644 index 0000000..a68b81f --- /dev/null +++ b/src/platforms/Gitea/URLHelper.ts @@ -0,0 +1,70 @@ +import { raiseError } from 'analytics' + +export function parse(): Partial & { path: string[] } { + const { pathname } = window.location + let [ + , + // ignore content before the first '/' + userName, + repoName, + type, + ...path // should be [...branchName.split('/'), ...filePath.split('/')] + ] = unescape(decodeURIComponent(pathname)).split('/') + return { + userName, + repoName, + branchName: undefined, + type, + path, + } +} + +export function parseSHA() { + const { type, path } = parse() + return type === 'blob' || type === 'tree' ? path[0] : undefined +} + +function isCommitPath(path: string[]) { + return isCompleteCommitSHA(path[0]) +} + +function isCompleteCommitSHA(sha?: string) { + return typeof sha === 'string' && /^[abcdef0-9]{40}$/i.test(sha) +} + +export function getCurrentPath(branchName = '') { + const { path, type } = parse() + if (type === 'blob' || type === 'tree') { + if (isCommitPath(path)) { + // path = commit-SHA/path/to/item + path.shift() + } else { + // path = branch/name/path/to/item or HEAD/path/to/item + // HEAD is not a valid branch name. Getting HEAD means being detached. + if (path[0] === 'HEAD') path.shift() + else { + const splitBranchName = branchName.split('/') + while (splitBranchName.length) { + if ( + splitBranchName[0] === path[0] || + // Keep consuming as their heads are same + (splitBranchName.length === 1 && splitBranchName[0].startsWith(path[0])) + // This happens when visiting URLs like /blob/{commitSHA}/path/to/file + // and {commitSHA} is shorter than we got from DOM + ) { + splitBranchName.shift() + path.shift() + } else { + raiseError(new Error(`branch name and path prefix not match`), { + branchName, + path: parse().path, + }) + return [] + } + } + } + } + return path.map(decodeURIComponent) + } + return [] +} \ No newline at end of file diff --git a/src/platforms/Gitea/index.ts b/src/platforms/Gitea/index.ts new file mode 100644 index 0000000..44ce601 --- /dev/null +++ b/src/platforms/Gitea/index.ts @@ -0,0 +1,160 @@ +import { platform } from 'platforms' +import { resolveGitModules } from 'utils/gitSubmodule' +import { sortFoldersToFront } from 'utils/treeParser' +import * as API from './API' +import * as DOMHelper from './DOMHelper' +import * as URLHelper from './URLHelper' + +function processTree(tree: TreeNode[]): TreeNode { + // nodes are created from items and put onto tree + const pathToItem = new Map() + tree.forEach(item => pathToItem.set(item.path, item)) + + const pathToCreated = new Map() + const root: TreeNode = { name: '', path: '', contents: [], type: 'tree' } + pathToCreated.set('', root) + tree.forEach(item => { + // bottom-up search for the deepest node created + let path = item.path + const itemsToCreateTreeNode: TreeNode[] = [] + while (path !== '' && !pathToCreated.has(path)) { + const item = pathToItem.get(path) + if (item) { + itemsToCreateTreeNode.push(item) + } else { + const $item: TreeNode = { + name: path.split('/').pop() || '', + path, + type: 'tree', + contents: [], + } + pathToItem.set(path, $item) + itemsToCreateTreeNode.push($item) + } + // 'a/b' -> 'a' + // 'a' -> '' + path = path.substring(0, path.lastIndexOf('/')) + } + + // top-down create nodes + while (itemsToCreateTreeNode.length) { + const item = itemsToCreateTreeNode.pop() + if (!item) continue + const node: TreeNode = item + const parentNode = pathToCreated.get(path) + if (parentNode) { + if (!parentNode.contents) parentNode.contents = [] + parentNode.contents.push(node) + } + pathToCreated.set(node.path, node) + path = node.path + } + }) + + sortFoldersToFront(root) + + return root +} + +function getUrlForRedirect( + userName: string, + repoName: string, + branchName: string, + type = 'blob', + path = '', +) { + // Modern browsers have great support for handling unsafe URL, + // It may be possible to sanitize path with + // `path => path.includes('#') ? path.replace(/#/g, '%23') : '...' + return `https://${ + window.location.host + }/${userName}/${repoName}/${type}/${branchName}/${path + .split('/') + .map(encodeURIComponent) + .join('/')}` +} + +export const Gitea: Platform = { + isEnterprise() { + return false; + }, + resolveMeta() { + if (!DOMHelper.isInRepoPage()) { + return null + } + + let detectedBranchName + if (DOMHelper.isInCodePage()) { + detectedBranchName = DOMHelper.getCurrentBranch() || URLHelper.parseSHA() + } + + const metaData = { + ...URLHelper.parse(), + branchName: detectedBranchName, + } as MetaData + return metaData + }, + async getMetaData(partialMetaData, accessToken) { + const { userName, repoName } = partialMetaData + const data = await API.getRepoMeta(userName, repoName, accessToken) + return { + userUrl: data?.owner?.html_url, + repoUrl: data?.html_url, + defaultBranchName: data.default_branch, + } + }, + async getTreeData(metaData, path, recursive, accessToken) { + const { userName, repoName, branchName } = metaData + const treeData = await API.getTreeData(userName, repoName, branchName) + + const root = processTree( + treeData.tree.map(item => ({ + path: item.path || '', + type: item.type || 'blob', + name: item.path?.replace(/^.*\//, '') || '', + url: + item.url && item.type && item.path + ? getUrlForRedirect( + metaData.userName, + metaData.repoName, + metaData.branchName, + item.type, + item.path, + ) + : undefined, + contents: item.type === 'tree' ? [] : undefined, + sha: item.sha, + })), + ) + + const gitModules = root.contents?.find(item => item.name === '.gitmodules') + if (gitModules) { + if (metaData.userName && metaData.repoName && gitModules.sha) { + const blobData = await API.getBlobData( + metaData.repoName, + metaData.userName, + gitModules.sha, + accessToken, + ) + + if (blobData && blobData.encoding === 'base64' && blobData.content) { + await resolveGitModules(root, Base64.decode(blobData.content)) + } + } + } + + return { root } + }, + shouldShow() { + return DOMHelper.isInCodePage() + }, + getCurrentPath(branchName) { + return URLHelper.getCurrentPath(branchName) + }, + setOAuth(code) { + return API.OAuth(code) + }, + getOAuthLink() { + return `https://${window.location.host}/api/v1/user/applications/oauth2` + }, +} \ No newline at end of file diff --git a/src/platforms/index.ts b/src/platforms/index.ts index bdb82e5..4e713a4 100644 --- a/src/platforms/index.ts +++ b/src/platforms/index.ts @@ -1,5 +1,6 @@ import { dummyPlatformForTypeSafety } from './dummyPlatformForTypeSafety' import { Gitee } from './Gitee' +import { Gitea } from './Gitea' import { GitHub } from './GitHub' const platforms: { @@ -7,6 +8,7 @@ const platforms: { } = { GitHub: GitHub, Gitee: Gitee, + Gitea: Gitea, } function resolvePlatform() {