diff --git a/src/platforms/GitHub/API.ts b/src/platforms/GitHub/API.ts index 7dbf825..cfa59bc 100644 --- a/src/platforms/GitHub/API.ts +++ b/src/platforms/GitHub/API.ts @@ -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( url: string, { @@ -203,7 +194,10 @@ export async function requestCommitTreeData( return await request(url, { accessToken }, responseBodyResolvers.asIs) } -export async function getPaginatedData(sendRequest: (page: number) => Promise) { +export async function getPaginatedData( + // TODO: refactor, update sendRequest arguments with URLs in response header `link` + sendRequest: (page: number) => Promise, +) { const responses: Response[] = [] let page = 1 // eslint-disable-next-line no-constant-condition @@ -237,5 +231,5 @@ export async function getPaginatedData(sendRequest: (page: number) => Promise break } } - return Promise.all(responses.map(responseBodyResolvers.json)) as Promise + return Promise.all(responses.map(responseBodyResolvers.json) as T[]) } diff --git a/src/platforms/GitLab/API.ts b/src/platforms/GitLab/API.ts new file mode 100644 index 0000000..11c0f7e --- /dev/null +++ b/src/platforms/GitLab/API.ts @@ -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( + url: string, + { + accessToken, + }: { + accessToken?: string + } = {}, + bodyResolver: (response: Response) => Async = 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 | null)?.message + : `${content}` + throw new Error(`Unknown message content "${message}"`) +} + +export async function getRepoMeta( + userName: string, + repoName: string, + accessToken?: string, +): Promise { + 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 { + 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 { + const url = `${API_ENDPOINT}/projects/${encodeURIComponent( + `${userName}/${repoName}`, + )}/repository/files/?${new URLSearchParams({})}` + return await request(url, { accessToken }) +} + +export async function OAuth(code: string): Promise { + 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( + initialParams: URLSearchParams, + sendRequest: (params: URLSearchParams) => Promise, +) { + 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[]) +} diff --git a/src/platforms/GitLab/DOMHelper.ts b/src/platforms/GitLab/DOMHelper.ts new file mode 100644 index 0000000..8064d06 --- /dev/null +++ b/src/platforms/GitLab/DOMHelper.ts @@ -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, + ) +} diff --git a/src/platforms/GitLab/Request.d.ts b/src/platforms/GitLab/Request.d.ts new file mode 100644 index 0000000..cbe4387 --- /dev/null +++ b/src/platforms/GitLab/Request.d.ts @@ -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 + } + } +} diff --git a/src/platforms/GitLab/URLHelper.ts b/src/platforms/GitLab/URLHelper.ts new file mode 100644 index 0000000..d8e5c92 --- /dev/null +++ b/src/platforms/GitLab/URLHelper.ts @@ -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 & { 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 [] +} diff --git a/src/platforms/GitLab/index.ts b/src/platforms/GitLab/index.ts new file mode 100644 index 0000000..13986ba --- /dev/null +++ b/src/platforms/GitLab/index.ts @@ -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() + 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 +} + +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( + 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() + }, +} diff --git a/src/platforms/GitLab/utils.ts b/src/platforms/GitLab/utils.ts new file mode 100644 index 0000000..75c165d --- /dev/null +++ b/src/platforms/GitLab/utils.ts @@ -0,0 +1,78 @@ +/** + * Resolved from response header `link` + * + * Example: + * ; rel="next",\ + * ; rel="first",\ + * ; 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 + } +} diff --git a/src/platforms/index.ts b/src/platforms/index.ts index cc85789..f9a85ad 100644 --- a/src/platforms/index.ts +++ b/src/platforms/index.ts @@ -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() { diff --git a/src/utils/networkService.ts b/src/utils/networkService.ts index caf347e..ed3bf18 100644 --- a/src/utils/networkService.ts +++ b/src/utils/networkService.ts @@ -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) { + 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 '' +}