Merge pull request #118 from BbsonLin/develop

Support platform Gitea
This commit is contained in:
Enix 2021-01-05 23:20:41 +08:00 committed by GitHub
commit 453c40553b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 407 additions and 0 deletions

107
src/platforms/Gitea/API.ts Normal file
View file

@ -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<GiteaAPI.MetaData> {
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<GiteaAPI.TreeData> {
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<GitHubAPI.BlobData> {
const url = `https://${API_ENDPOINT}/repos/${userName}/${repoName}/git/blobs/${sha}`
return await request(url, { accessToken })
}
export async function OAuth(code: string): Promise<string | null> {
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
}

View file

@ -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('.text > strong')
if (branchNameElement) {
return branchNameElement.textContent;
}
raiseError(new Error('cannot get current branch'))
}

42
src/platforms/Gitea/Request.d.ts vendored Normal file
View file

@ -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
}
}

View file

@ -0,0 +1,70 @@
import { raiseError } from 'analytics'
export function parse(): Partial<MetaData> & { 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 []
}

View file

@ -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<string, TreeNode>()
tree.forEach(item => pathToItem.set(item.path, item))
const pathToCreated = new Map<string, TreeNode>()
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}/src/branch/${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, recursive)
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`
},
}

View file

@ -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() {

View file

@ -8,6 +8,8 @@ const config: Config = {
'.repository-content',
// gitee
'#git-project-content',
// gitea
'.repository > .ui.container'
],
update: {
css: false,