This commit is contained in:
EnixCoda 2023-01-01 21:42:01 +08:00
parent f121657fc1
commit 1cd5ba57e4
9 changed files with 648 additions and 12 deletions

View file

@ -1,7 +1,7 @@
import { errors } from 'platforms'
import { isEnterprise } from '.'
import { is } from '../../utils/is'
import { gitakoServiceHost } from '../../utils/networkService'
import { gitakoServiceHost, responseBodyResolvers } from '../../utils/networkService'
import { continuousLoadPages, getDOM, resolveHeaderLink } from './utils'
function isAPIRateLimitExceeded(content: JSONValue) {
@ -19,15 +19,6 @@ function isBlockedProject(content: JSONValue) {
return is.JSON.object(content) && content?.['message'] === 'Repository access blocked'
}
export const responseBodyResolvers = {
asIs: (response: Response) => response,
json(response: Response) {
const contentType = response.headers.get('Content-Type') || response.headers.get('content-type')
if (contentType?.includes('application/json')) return response.json()
throw new Error(`Response content type is "${contentType}"`)
},
}
async function request<T>(
url: string,
{
@ -203,7 +194,10 @@ export async function requestCommitTreeData(
return await request(url, { accessToken }, responseBodyResolvers.asIs)
}
export async function getPaginatedData<T>(sendRequest: (page: number) => Promise<Response>) {
export async function getPaginatedData<T>(
// TODO: refactor, update sendRequest arguments with URLs in response header `link`
sendRequest: (page: number) => Promise<Response>,
) {
const responses: Response[] = []
let page = 1
// eslint-disable-next-line no-constant-condition
@ -237,5 +231,5 @@ export async function getPaginatedData<T>(sendRequest: (page: number) => Promise
break
}
}
return Promise.all(responses.map(responseBodyResolvers.json)) as Promise<T[]>
return Promise.all(responses.map(responseBodyResolvers.json) as T[])
}

163
src/platforms/GitLab/API.ts Normal file
View file

@ -0,0 +1,163 @@
import { errors } from 'platforms'
import { is } from 'utils/is'
import {
gitakoServiceHost,
responseBodyResolvers,
transformURLSearchParam,
} from 'utils/networkService'
import { resolveHeaderLink } from './utils'
const API_ENDPOINT = `${window.location.origin}/api/v4`
function isAPIRateLimitExceeded(content: JSONValue) {
return (
is.JSON.object(content) &&
content?.['documentation_url'] === 'https://developer.github.com/v3/#rate-limiting'
)
}
function isEmptyProject(content: JSONValue) {
return is.JSON.object(content) && content?.['message'] === 'Git Repository is empty.'
}
function isBlockedProject(content: JSONValue) {
return is.JSON.object(content) && content?.['message'] === 'Repository access blocked'
}
async function request<T>(
url: string,
{
accessToken,
}: {
accessToken?: string
} = {},
bodyResolver: (response: Response) => Async<T> = responseBodyResolvers.json,
) {
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)
}
// 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 bodyResolver(res)
if (res.status === 404 || res.status === 401) throw new Error(errors.NOT_FOUND)
if (res.status === 403) throw new Error(errors.API_RATE_LIMIT)
if (res.status === 500) throw new Error(errors.SERVER_FAULT)
const content = await responseBodyResolvers.json(res)
if (isAPIRateLimitExceeded(content)) throw new Error(errors.API_RATE_LIMIT)
if (isEmptyProject(content)) throw new Error(errors.EMPTY_PROJECT)
if (isBlockedProject(content)) throw new Error(errors.BLOCKED_PROJECT)
const message =
typeof content === 'object'
? (content as Record<string, unknown> | null)?.message
: `${content}`
throw new Error(`Unknown message content "${message}"`)
}
export async function getRepoMeta(
userName: string,
repoName: string,
accessToken?: string,
): Promise<GitLabAPI.Response.MetaData> {
const url = `${API_ENDPOINT}/projects/${encodeURIComponent(`${userName}/${repoName}`)}`
return await request(url, { accessToken })
}
export async function getTreeData(
userName: string,
repoName: string,
searchParams?: URLSearchParams | NewType,
accessToken?: string,
): Promise<GitLabAPI.Response.TreeData> {
return (await requestTreeData(userName, repoName, searchParams, accessToken)).json()
}
type NewType = {
ref?: string
path?: string
recursive?: boolean
id?: string
page?: number
per_page?: number
pagination?: 'legacy'
}
export function requestTreeData(
userName: string,
repoName: string,
searchParams?: URLSearchParams | NewType,
accessToken?: string,
) {
const url = `${API_ENDPOINT}/projects/${encodeURIComponent(
`${userName}/${repoName}`,
)}/repository/tree/?${
searchParams instanceof URLSearchParams
? searchParams
: searchParams
? transformURLSearchParam(searchParams)
: ''
}`
return request(url, { accessToken }, responseBodyResolvers.asIs)
}
export async function getBlobData(
userName: string,
repoName: string,
sha: string,
accessToken?: string,
): Promise<GitLabAPI.Response.BlobData> {
const url = `${API_ENDPOINT}/projects/${encodeURIComponent(
`${userName}/${repoName}`,
)}/repository/files/?${new URLSearchParams({})}`
return await request(url, { accessToken })
}
export async function OAuth(code: string): Promise<string | null> {
const endpoint = `https://${gitakoServiceHost}/oauth/gitee?${new URLSearchParams({ code })}`
const res = await fetch(endpoint, {
method: 'post',
})
if (res.ok) {
const body = await res.json()
const accessToken = body?.accessToken
if (typeof accessToken === 'string') return accessToken
}
return null
}
export async function getPaginatedData<T>(
initialParams: URLSearchParams,
sendRequest: (params: URLSearchParams) => Promise<Response>,
) {
const responses: Response[] = []
const run = async (params: URLSearchParams) => {
const response = await sendRequest(params)
responses.push(response)
const headerLink = response.headers.get('link')
if (headerLink) {
const rels = resolveHeaderLink(headerLink)
if (rels?.next) await run(new URL(rels.next).searchParams)
}
}
await run(initialParams)
return Promise.all(responses.map(responseBodyResolvers.json) as T[])
}

View file

@ -0,0 +1,37 @@
import { raiseError } from 'analytics'
import { $ } from 'utils/$'
export function isInRepoPage() {
const repoHeaderSelector = '.project-highlight-puc'
return Boolean($(repoHeaderSelector))
}
export function isInCodePage() {
const branchListSelector = '.file-holder'
return Boolean($(branchListSelector))
}
export function getCurrentBranch() {
const selectedBranchButtonSelector = [
'.project-refs-form .dropdown-toggle-text',
'.tree-ref-holder .gl-dropdown-button-text',
].join(',')
const element = $(selectedBranchButtonSelector)
if (element) {
const partialBranchNameFromInnerText = element.textContent
if (!partialBranchNameFromInnerText?.includes('…')) return partialBranchNameFromInnerText
}
raiseError(new Error('cannot get current branch'))
}
const REPO_TYPE_PRIVATE = 'private' as const
const REPO_TYPE_PUBLIC = 'public' as const
export function getRepoPageType() {
const headerSelector = `.git-project-title .icon-lock`
return $(
headerSelector,
() => REPO_TYPE_PRIVATE,
() => REPO_TYPE_PUBLIC,
)
}

64
src/platforms/GitLab/Request.d.ts vendored Normal file
View file

@ -0,0 +1,64 @@
declare namespace GitLabAPI {
namespace Request {}
namespace Response {
type TreeItem = {
id: string
name: string
path: string
mode: string
type: 'blob' | 'commit' | 'tree'
}
type TreeData = TreeItem[]
type MetaData = {
id: number
description: string
name: string
name_with_namespace: string
path: string
path_with_namespace: string
created_at: string
default_branch: string
tag_list: string[]
topics: string[]
ssh_url_to_repo: string
http_url_to_repo: string
web_url: string
readme_url: string
avatar_url: string
forks_count: number
star_count: number
last_activity_at: string
namespace: {
id: number
name: string
path: string
kind: string // 'group' or other enum
full_path: string
parent_id: string | null
avatar_url: string
web_url: string
}
}
type BlobData = {
file_name: string
file_path: string
size: number
encoding: string
content: string
content_sha256: string
ref: string
blob_id: string
commit_id: string
last_commit_id: string
execute_filemode: boolean
}
type OAuth = {
// TODO
}
}
}

View file

@ -0,0 +1,86 @@
import { raiseError } from 'analytics'
import { sanitizedLocation } from 'utils/URLHelper'
export function getPageUrl(
userName: string,
repoName?: string,
branchName?: string,
type?: string,
path?: string,
) {
if (path) return `${origin}/${userName}/${repoName}/-/${type}/${branchName}/${path}`
if (type) return `${origin}/${userName}/${repoName}/-/${type}/${branchName}`
if (branchName) return `${origin}/${userName}/${repoName}/-/${type}`
if (repoName) return `${origin}/${userName}/${repoName}`
return `${origin}/${userName}`
}
// gitlab-com/www-gitlab-com/-/blob/mb/k8s-migration-wg-closing-remarks/.tool-versions
export function parse(): Partial<MetaData> & { path: string[] } {
const [
,
// ignore content before the first '/'
userName,
repoName,
dash, // `-`
type,
...path // should be [...branchName.split('/'), ...filePath.split('/')]
] = unescape(decodeURIComponent(sanitizedLocation.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,181 @@
import { GITEE_OAUTH } from 'env'
import { Base64 } from 'js-base64'
import { resolveGitModules } from 'utils/gitSubmodule'
import { useProgressBar } from 'utils/hooks/useProgressBar'
import { gitakoServiceHost, transformURLSearchParam } from 'utils/networkService'
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
}
const origin = window.location.origin
export const GitLab: Platform = {
isEnterprise() {
return !origin.endsWith('gitlab.com')
},
resolvePartialMetaData() {
if (!DOMHelper.isInRepoPage()) {
return null
}
let branchName
if (DOMHelper.isInCodePage()) {
// not working well with non-branch blob
// cannot handle '/' split branch name, should not use when possibly on branch page
branchName = (DOMHelper.getCurrentBranch() || URLHelper.parseSHA())?.trim()
}
const { userName, repoName, type } = URLHelper.parse()
if (!userName || !repoName) {
return null
}
const metaData = {
userName,
repoName,
type,
branchName,
}
return metaData
},
async getDefaultBranchName({ userName, repoName }, accessToken) {
const data = await API.getRepoMeta(userName, repoName, accessToken)
return data.default_branch
},
resolveUrlFromMetaData({ userName, repoName, branchName }) {
const repoUrl = `${origin}/${userName}/${repoName}`
const userUrl = `${origin}/${userName}`
const branchUrl = `${repoUrl}/-/tree/${branchName}`
return {
repoUrl,
userUrl,
branchUrl,
}
},
async getTreeData(metaData, path, recursive, accessToken) {
const { userName, repoName, branchName } = metaData
const treeData = (
await API.getPaginatedData<GitLabAPI.Response.TreeItem>(
transformURLSearchParam({
ref: branchName,
path,
}),
params => API.requestTreeData(userName, repoName, params, accessToken),
)
).flat()
const root = processTree(
treeData.map(item => ({
path: item.path,
type: item.type,
name: item.name,
url:
item.type && item.path
? URLHelper.getPageUrl(
metaData.userName,
metaData.repoName,
metaData.branchName,
item.type,
item.path,
)
: undefined,
contents: item.type === 'tree' ? [] : undefined,
sha: item.id,
})),
)
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, defer: true }
},
shouldExpandSideBar() {
return DOMHelper.isInCodePage()
},
getCurrentPath(branchName) {
return URLHelper.getCurrentPath(branchName)
},
getOAuthLink() {
const params = new URLSearchParams({
client_id: GITEE_OAUTH.clientId,
scope: 'projects',
response_type: 'code',
redirect_uri: `https://${gitakoServiceHost}/redirect/?${new URLSearchParams({
redirect: window.location.href,
})}`,
})
return `${origin}/oauth/authorize?${params}`
},
setOAuth(code) {
return API.OAuth(code)
},
usePlatformHooks() {
useProgressBar()
},
}

View file

@ -0,0 +1,78 @@
/**
* Resolved from response header `link`
*
* Example:
* <https://gitlab.com/api/v4/projects/gitlab-com%2Fwww-gitlab-com/repository/tree?id=gitlab-com%2Fwww-gitlab-com&page=2&pagination=legacy&path=&per_page=100&recursive=true&ref=master>; rel="next",\
* <https://gitlab.com/api/v4/projects/gitlab-com%2Fwww-gitlab-com/repository/tree?id=gitlab-com%2Fwww-gitlab-com&page=1&pagination=legacy&path=&per_page=100&recursive=true&ref=master>; rel="first",\
* <https://gitlab.com/api/v4/projects/gitlab-com%2Fwww-gitlab-com/repository/tree?id=gitlab-com%2Fwww-gitlab-com&page=308&pagination=legacy&path=&per_page=100&recursive=true&ref=master>; rel="last"
*
* `rel` existence
*
* rel | first page | middle page | last page
* first| | |
* last | | |
* next | | |
* prev | | |
*
*/
type Rels = {
next?: string
last?: string
prev?: string
first?: string
}
export function resolveHeaderLink(raw: string) {
const rels: Rels = {}
raw
.split(',')
.map(part => part.match(/<(.*?)>; *rel="(.*?)"/))
.filter((link: RegExpMatchArray | null): link is RegExpMatchArray => !!link)
.forEach(([, url, rel]) => {
// It's 2022, is there a smarter way to do this in TS?
switch (rel) {
case 'next':
rels.next = url
break
case 'last':
rels.last = url
break
case 'prev':
rels.prev = url
break
case 'first':
rels.first = url
break
}
})
if (rels.next && rels.last && !rels.prev && rels.first) {
// first page
return {
first: rels.first,
last: rels.last,
next: rels.next,
position: 'first' as const,
}
} else if (rels.next && rels.last && rels.prev && rels.first) {
// middle page
return {
first: rels.first,
last: rels.last,
next: rels.next,
prev: rels.prev,
position: 'middle' as const,
}
} else if (!rels.next && !rels.last && rels.prev && rels.first) {
// last page
return {
first: rels.first,
last: rels.last,
prev: rels.prev,
position: 'last' as const,
}
} else {
// unexpected link header content
return
}
}

View file

@ -3,11 +3,13 @@ import { dummyPlatformForTypeSafety } from './dummyPlatformForTypeSafety'
import { Gitea } from './Gitea'
import { Gitee } from './Gitee'
import { GitHub } from './GitHub'
import { GitLab } from './GitLab'
const platforms = {
GitHub,
Gitee,
Gitea,
GitLab,
}
function resolvePlatform() {

View file

@ -1 +1,32 @@
export const gitakoServiceHost = 'gitako.enix.one'
export const responseBodyResolvers = {
asIs: (response: Response) => response,
json(response: Response) {
const contentType = response.headers.get('Content-Type') || response.headers.get('content-type')
if (contentType?.includes('application/json')) return response.json()
throw new Error(`Response content type is "${contentType}"`)
},
}
export function transformURLSearchParam(source: Record<string, unknown>) {
return Object.entries(source).reduce((param, [key, value]) => {
if (Array.isArray(value))
for (const i of value) param.append(key, stringifyValueForURLSearchParam(i))
else param.append(key, stringifyValueForURLSearchParam(value))
return param
}, new URLSearchParams())
}
export function stringifyValueForURLSearchParam(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'boolean') return `${value}`
if (typeof value === 'number') {
if (Number.isNaN(value)) return ''
return `${value}`
}
if (typeof value === null) return 'null'
return ''
}