Skip to content

Commit

Permalink
GKCS-5303 Allow the browser extension to connect to On-premise GitHub…
Browse files Browse the repository at this point in the history
… environments (#21)

* Add GitHub Enterprise support

* Simplify injection by reloading the extension

* Add new GHE-specific injection for repo code page
  • Loading branch information
gitkrakel authored Mar 1, 2024
1 parent d3640fe commit b89b861
Show file tree
Hide file tree
Showing 8 changed files with 205 additions and 41 deletions.
93 changes: 68 additions & 25 deletions src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,23 @@ import { injectionScope as inject_bitbucket } from './hosts/bitbucket';
import { injectionScope as inject_github } from './hosts/github';
import { injectionScope as inject_gitlab } from './hosts/gitlab';
import { refreshPermissions } from './permissions-helper';
import { PopupInitMessage } from './shared';

webNavigation.onDOMContentLoaded.addListener(injectScript, {
url: [
{ hostContains: 'github.com' },
{ hostContains: 'gitlab.com' },
{ hostContains: 'bitbucket.org' },
{ hostContains: 'dev.azure.com' },
],
});
import { getEnterpriseConnections, PermissionsGrantedMessage, PopupInitMessage } from './shared';
import type { CacheContext } from './types';
import { Provider } from './types';

interface InjectionDomains {
github: string[];
gitlab: string[];
bitbucket: string[];
azureDevops: string[];
}

const DefaultInjectionDomains: InjectionDomains = {
github: ['github.com'],
gitlab: ['gitlab.com'],
bitbucket: ['bitbucket.org'],
azureDevops: ['dev.azure.com']
};

webNavigation.onHistoryStateUpdated.addListener(details => {
// used to detect when the user navigates to a different page in the same tab
Expand All @@ -30,49 +37,85 @@ webNavigation.onHistoryStateUpdated.addListener(details => {

runtime.onMessage.addListener(async (msg) => {
if (msg === PopupInitMessage) {
return refreshPermissions();
const context: CacheContext = {};
return refreshPermissions(context);
} else if (msg === PermissionsGrantedMessage) {
// Reload extension to update injection listener
runtime.reload();
return undefined;
}
console.error('Recevied unknown runtime message', msg);
return undefined;
});

function injectScript(details: WebNavigation.OnDOMContentLoadedDetailsType) {
void scripting.executeScript({
target: { tabId: details.tabId },
// injectImmediately: true,
func: getInjectionFn(details.url),
args: [details.url],
async function computeInjectionDomains(context: CacheContext) {
const injectionDomains = structuredClone(DefaultInjectionDomains);
const enterpriseConnections = await getEnterpriseConnections(context);
if (enterpriseConnections) {
for (const connection of enterpriseConnections) {
if (connection.provider === Provider.GITHUB_ENTERPRISE) {
injectionDomains.github.push(connection.domain);
}
}
}
return injectionDomains;
}

async function addInjectionListener(context: CacheContext) {
const injectionDomains = await computeInjectionDomains(context);
const allDomains = Object.values<string[]>(injectionDomains as any).flat();

// note: This is a closure over injectionDomains
const injectScript = (details: WebNavigation.OnDOMContentLoadedDetailsType) => {
void scripting.executeScript({
target: { tabId: details.tabId },
// injectImmediately: true,
func: getInjectionFn(details.url, injectionDomains),
args: [details.url],
});
};

webNavigation.onDOMContentLoaded.addListener(injectScript, {
url: allDomains.map((domain) => ({ hostContains: domain })),
});
}

function getInjectionFn(url: string): (url: string) => void {
const uri = new URL(url);
if (uri.hostname.endsWith('github.com')) {
function urlHostHasDomain(url: URL, domains: string[]): boolean {
return domains.some((domain) => url.hostname.endsWith(domain));
}

function getInjectionFn(rawUrl: string, injectionDomains: InjectionDomains): (url: string) => void {
const url = new URL(rawUrl);
if (urlHostHasDomain(url, injectionDomains.github)) {
return inject_github;
}

if (uri.hostname.endsWith('gitlab.com')) {
if (urlHostHasDomain(url, injectionDomains.gitlab)) {
return inject_gitlab;
}

if (uri.hostname.endsWith('bitbucket.org')) {
if (urlHostHasDomain(url, injectionDomains.bitbucket)) {
return inject_bitbucket;
}

if (uri.hostname.endsWith('dev.azure.com')) {
if (urlHostHasDomain(url, injectionDomains.azureDevops)) {
return inject_azureDevops;
}

console.error('Unsupported host');
throw new Error('Unsupported host');
}

const main = async () => {
async function main() {
// The fetchUser function also updates the extension icon if the user is logged in
await fetchUser();

const context: CacheContext = {};
// This removes unneded permissions
await refreshPermissions();
await refreshPermissions(context);
// NOTE: This may request hosts that we may not have permissions for, which will log errors for the extension
// This does not cause any issues, and eliminating the errors requires more logic
await addInjectionListener(context);
};

void main();
22 changes: 21 additions & 1 deletion src/gkApi.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cookies } from 'webextension-polyfill';
import { checkOrigins } from './permissions-helper';
import { updateExtensionIcon } from './shared';
import type { User } from './types';
import type { ProviderConnection, User } from './types';

declare const MODE: 'production' | 'development' | 'none';

Expand Down Expand Up @@ -72,3 +72,23 @@ export const logoutUser = async () => {

await updateExtensionIcon(false);
};

export const getProviderConnections = async (): Promise<ProviderConnection[] | null> => {
const token = await getAccessToken();
if (!token) {
return null;
}

const res = await fetch(`${gkApiUrl}/v1/provider-tokens/`, {
headers: {
Authorization: `Bearer ${token}`,
},
});

if (!res.ok) {
return null;
}

const payload = await res.json();
return payload.data as ProviderConnection[];
};
11 changes: 11 additions & 0 deletions src/hosts/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,17 @@ export function injectionScope(url: string) {
}
case 'tree':
case undefined:
// Enterpise v3.11.2
insertions.set('[data-target="get-repo.modal"] ul li:last-child', {
html: /*html*/ `<li data-gk class="Box-row Box-row--hover-gray p-3 mt-0 rounded-0">
<a class="d-flex flex-items-center color-fg-default text-bold no-underline" href="${url}" target="_blank" title="${label}" aria-label="${label}">
${this.getGitKrakenSvg(16, 'mr-2')}
${label}
</a>
</li>`,
position: 'afterend',
});

insertions.set('[data-target="get-repo.modal"] #local-panel ul li:first-child', {
html: /*html*/ `<li data-gk class="Box-row Box-row--hover-gray p-3 mt-0 rounded-0">
<a class="d-flex flex-items-center color-fg-default text-bold no-underline" href="${url}" target="_blank" title="${label}" aria-label="${label}">
Expand Down
25 changes: 19 additions & 6 deletions src/permissions-helper.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Permissions } from 'webextension-polyfill';
import { permissions } from 'webextension-polyfill';
import { arrayDifference, CloudProviders } from './shared';
import { arrayDifference, CloudProviders, getEnterpriseConnections } from './shared';
import type { CacheContext } from './types';

function domainToMatchPattern(domain: string): string {
return `*://*.${domain}/*`;
Expand All @@ -12,21 +13,32 @@ const RequiredOriginPatterns = [
].map(domainToMatchPattern);
const CloudProviderOriginPatterns = CloudProviders.map(domainToMatchPattern);

export type OriginTypes = 'required' | 'cloud';
async function computeEnterpriseOriginPatterns(context: CacheContext): Promise<string[] | undefined> {
const enterpriseConnections = await getEnterpriseConnections(context);
if (!enterpriseConnections) {
return;
}
return enterpriseConnections.map((x) => domainToMatchPattern(x.domain));
}

export type OriginTypes = 'required' | 'cloud' | 'enterprise';

export interface PermissionsRequest {
request: Permissions.Permissions;
hasRequired: boolean;
hasCloud: boolean;
hasEnterprise: boolean;
}

export async function refreshPermissions(): Promise<PermissionsRequest | undefined> {
export async function refreshPermissions(context: CacheContext): Promise<PermissionsRequest | undefined> {
const exitingPermissions = await permissions.getAll();

const newRequiredOrigins = arrayDifference(RequiredOriginPatterns, exitingPermissions.origins);
const enterpriseOrigins = await computeEnterpriseOriginPatterns(context);
const newEnterpriseOrigins = arrayDifference(enterpriseOrigins, exitingPermissions.origins);
const newCloudOrigins = arrayDifference(CloudProviderOriginPatterns, exitingPermissions.origins);
const newOrigins = [...newRequiredOrigins, ...newCloudOrigins];
const unusedOrigins = arrayDifference(exitingPermissions.origins, [...RequiredOriginPatterns, ...CloudProviderOriginPatterns]);
const newOrigins = [...newRequiredOrigins, ...newEnterpriseOrigins, ...newCloudOrigins];
const unusedOrigins = arrayDifference(exitingPermissions.origins, [...RequiredOriginPatterns, ...CloudProviderOriginPatterns, ...(enterpriseOrigins ?? [])]);

if (!unusedOrigins.length) {
const unusedPermissions: Permissions.Permissions = {
Expand All @@ -43,7 +55,8 @@ export async function refreshPermissions(): Promise<PermissionsRequest | undefin
origins: newOrigins,
},
hasRequired: Boolean(newRequiredOrigins.length),
hasCloud: Boolean(newCloudOrigins.length)
hasCloud: Boolean(newCloudOrigins.length),
hasEnterprise: Boolean(newEnterpriseOrigins.length)
}
: undefined;
}
Expand Down
25 changes: 17 additions & 8 deletions src/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { permissions, runtime } from 'webextension-polyfill';
import { createAnchor, createFAIcon } from './domUtils';
import { fetchUser, logoutUser } from './gkApi';
import type { PermissionsRequest } from './permissions-helper';
import { PopupInitMessage } from './shared';
import { PermissionsGrantedMessage, PopupInitMessage } from './shared';
import type { User } from './types';

declare const MODE: 'production' | 'development' | 'none';
Expand Down Expand Up @@ -149,17 +149,18 @@ const syncWithBackground = async () => {
return await runtime.sendMessage(PopupInitMessage) as PermissionsRequest | undefined;
};

function reloadPopup() {
// This seems to work on Firefox and Chromium but I couldn't find any docs confirming this is the correct way
window.location.reload();
}
const sendPermissionsGranted = async () => {
await runtime.sendMessage(PermissionsGrantedMessage);
};

const renderPermissionRequest = (permissionsRequest: PermissionsRequest) => {
const mainEl = document.getElementById('main-content')!;

const permissionRequestLink = createAnchor('#', undefined, async () => {
await permissions.request(permissionsRequest.request);
reloadPopup();
const granted = await permissions.request(permissionsRequest.request);
if (granted) {
await sendPermissionsGranted();
}
});
permissionRequestLink.classList.add('alert');
if (permissionsRequest.hasRequired) {
Expand All @@ -174,7 +175,15 @@ const renderPermissionRequest = (permissionsRequest: PermissionsRequest) => {
supportLink.classList.add('menu-row');
mainEl.append(supportLink);
} else {
permissionRequestLink.append(createFAIcon('fa-triangle-exclamation'), `Allow permissions for cloud git providers`);
const typesRequested: string[] = [];
if (permissionsRequest.hasCloud) {
typesRequested.push('cloud');
}
if (permissionsRequest.hasEnterprise) {
typesRequested.push('self-hosted');
}

permissionRequestLink.append(createFAIcon('fa-triangle-exclamation'), `Allow permissions for ${typesRequested.join(' & ')} git providers`);
mainEl.append(permissionRequestLink);
}
};
Expand Down
50 changes: 50 additions & 0 deletions src/shared.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { action } from 'webextension-polyfill';
import { getProviderConnections } from './gkApi';
import type { CacheContext, EnterpriseProviderConnection, ProviderConnection } from './types';
import { Provider } from './types';

export const PopupInitMessage = 'popupInit';
export const PermissionsGrantedMessage = 'permissionsGranted';

const IconPaths = {
Grey: {
Expand Down Expand Up @@ -37,3 +41,49 @@ export function arrayDifference<T>(first: T[] | undefined, second: T[] | undefin
}
return first.filter(x => !second.includes(x));
}

function ensureDomain(value: string): string {
// Check if value is a URL or actually a domain
try {
const url = new URL(value);
return url.hostname;
} catch (e) {
// Not a valid URL, so it's probably a domain
if (!(e instanceof TypeError)) {
console.error('Unexpected error constructing URL', e);
}
}
return value;
}

async function cacheOnContext<K extends keyof CacheContext>(cache: CacheContext, key: K, fn: () => Promise<CacheContext[K] | undefined>): ReturnType<typeof fn> {
if (cache[key]) {
return cache[key];
}
const result = await fn();
if (result !== undefined) {
cache[key] = result;
}
return result;
}

function isEnterpriseProviderConnection(connection: ProviderConnection): connection is EnterpriseProviderConnection {
return Boolean((connection.provider === Provider.GITHUB_ENTERPRISE) && connection.domain);
}

export async function getEnterpriseConnections(context: CacheContext) {
return cacheOnContext(context, 'enterpriseConnectionsCache', async () => {
const providerConnections = await getProviderConnections();
if (!providerConnections) {
return;
}
// note: GitLab support comes later
const enterpriseConnections = providerConnections
.filter(isEnterpriseProviderConnection)
.map(
// typing is weird here, but we need to ensure domain is actually a domain
(connection: EnterpriseProviderConnection): EnterpriseProviderConnection => ({ ...connection, domain: ensureDomain(connection.domain) })
);
return enterpriseConnections;
});
}
17 changes: 17 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,20 @@ export interface User {
};
username: string;
}

export enum Provider {
GITHUB_ENTERPRISE = 'githubEnterprise',
}

export interface ProviderConnection {
provider: Provider;
type: string;
domain?: string; // NOTE: This could include the protocol scheme
}

// NOTE: domain here is actually a domain name, not a URI
export type EnterpriseProviderConnection = ProviderConnection & Required<Pick<ProviderConnection, 'domain'>>;

export interface CacheContext {
enterpriseConnectionsCache?: EnterpriseProviderConnection[];
}
3 changes: 2 additions & 1 deletion static/popup.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

/* Popup container */
#main-content {
width: 287px;
min-width: 287px;
padding: 8px;
font-size: var(--text-md);
}
Expand Down Expand Up @@ -50,6 +50,7 @@ button.menu-row {
display: flex;
align-items: center;
padding: 8px;
white-space: nowrap;
}
a.alert {
text-decoration: none;
Expand Down

0 comments on commit b89b861

Please sign in to comment.