Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GKCS-5303 Allow the browser extension to connect to On-premise GitHub environments #21

Merged
merged 3 commits into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading