diff --git a/README.md b/README.md index bcf604d..c6dc160 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Supported SCM platforms: - GitHub - GitLab +- Bitbucket Cloud Supported entry points @@ -81,7 +82,7 @@ You are good to go! You can also pin the extension to the toolbar for easy acces - Open a commit or merge request on your SCM platform. Open the extension popup dialog - Select the correct platform type and add the host -- Go to the options e.g., via click on the cog-wheel icon +- You will be redirected to the options page, where the configuration dialog opens up - Now enter your API token - ⚠️Don\'t give the token all permissions, use fine-grained personal access tokens (if possible) ⚠️ @@ -100,9 +101,25 @@ You are good to go! You can also pin the extension to the toolbar for easy acces 3. Select `Personal access tokens` - `Fine-grained tokens` 4. Select `Generate new token` 5. Set `Repository access` as desired. If you give access to non-public repositories, make sure to grant `Repository permissions` for `Content` (read-only) and `Pull requests` (read-only) - - GitLab 1. Go to `Settings` - `Access tokens` in the relevant group or repository 2. Select `Add new token` 3. Set **Scope** to `read_api`, select a **Role** that is allowed to access code and changes + - GitLab + 1. Go to `Settings` - `Access tokens` in the relevant group or repository + 2. Select `Add new token` + 3. Set **Scope** to `read_api`, select a **Role** that is allowed to access code and changes + - Bitbucket Cloud + - Personal access tokens + 1. Log in to https://id.atlassian.com/manage-profile/security/api-tokens. + 2. Select **Create API token with scopes**. + 3. Select **`read:repository:bitbucket`** and **`read:pullrequest:bitbucket`** scopes. + >*When using a Bitbucket personal access token, you must also provide the email address that belongs to the token in the extension’s configuration dialog.* + - Workspace/Project/Repository access tokens + 1. At https://bitbucket.org, navigate to the workspace, project or repository that you want the token to have access to. + 2. Open the corresponding **workspace**, **project** or **repository settings**. + 3. On the sidebar, under **Security**, select **Access tokens**. + 4. Select **Create access tokens**. + 5. Select **`repository`** and **`pullrequest`** permissions. - Configuration + Add host dialog + Configuration - Save the settings - Go back to the commit page and open the popup again @@ -110,12 +127,12 @@ You are good to go! You can also pin the extension to the toolbar for easy acces - Files that cannot be diffed with the ecu.test Diff-Viewer can still be opened individually for both the old and new versions - Click on a file and click on "Show new" or "Show old" - Configuration Download + Configuration Download - Files supported by the ecu.test Diff-Viewer will open directly in the viewer - Click on a file and click on "Show diff" - Configuration + Configuration - ecu.test Diff-Viewer will be opened diff --git a/docs/DeveloperGuide.md b/docs/DeveloperGuide.md index 589a970..29fb02c 100644 --- a/docs/DeveloperGuide.md +++ b/docs/DeveloperGuide.md @@ -51,7 +51,7 @@ Open the ecu.test diff project in your IDE of choice and run the following termi If you want to build the ecu.test diff extension locally for development purposes or for production use, you have access to different scripts: -**Note:** All of these scripts can be found and modified within the './package.json' of the project. +**Note:** All of these scripts can be found and modified within the `./package.json` of the project. **Firefox:** @@ -76,18 +76,9 @@ For integration and testing into your browser, you have to note some differences In **Chrome/Edge** you can select the `./dist` folder inside you browser windows for importing the extension. -In **Firefox**, you can test the extension only in debug-mode. -You have to klick on 'debug add-ons' and can afterward select a .zip file for the import. -You can create the .zip file on your own or use the tool web-ext (from mozilla). - -``` -npm install web-ext -cd ./dist -web-ext build -``` - -This tooling also provides help in the [signing process](https://extensionworkshop.com/documentation/develop/extensions-and-the-add-on-id/) -which is required for the use of add-ons without a debug-mode in firefox. +In **Firefox**, you can test the extension only in debug-mode on the [debugging page](about:debugging#/runtime/this-firefox) (click on 'debug add-ons' when managing addons). +You have to . The [debugging page](about:debugging#/runtime/this-firefox) opens. +Select a typical file in `./dist` folder, e.g. `./dist/manifest.json`. ## Testing / Linting / Code formatting @@ -100,7 +91,7 @@ Select the files you want to format and run the following command: ```bash # check all files -npm prettier:check +npm run prettier:check # fix findings for all files npm run prettier:write . @@ -192,6 +183,15 @@ See the following store-specific information, to handle release specification in - a mozilla developer account is required - any secrets that are necessary for the publication process are set as `Actions secret` - any additional information, see [Submitting an add-on](https://extensionworkshop.com/documentation/publish/submitting-an-add-on/) + - _Note_: + - The tool `web-ext` (from mozilla) will help you in the [signing process](https://extensionworkshop.com/documentation/develop/extensions-and-the-add-on-id/) which is required for the use of add-ons without a debug-mode in firefox. + + For example, install it globally. + ``` + npm install web-ext -g + cd ./dist + web-ext build + ``` - after publishing the application, a review is usually pending and will be published afterward - _Note_: - the add-on may be subject to additional review. diff --git a/docs/images/chrome/add_host_dialog.png b/docs/images/chrome/add_host_dialog.png new file mode 100644 index 0000000..96b4c90 Binary files /dev/null and b/docs/images/chrome/add_host_dialog.png differ diff --git a/docs/images/chrome/configuration.png b/docs/images/chrome/configuration.png index 606d514..808db62 100644 Binary files a/docs/images/chrome/configuration.png and b/docs/images/chrome/configuration.png differ diff --git a/docs/images/chrome/dialog.png b/docs/images/chrome/dialog.png index 12910ad..31d34df 100644 Binary files a/docs/images/chrome/dialog.png and b/docs/images/chrome/dialog.png differ diff --git a/docs/images/chrome/dialog_download.png b/docs/images/chrome/dialog_download.png index 47b1deb..dee4216 100644 Binary files a/docs/images/chrome/dialog_download.png and b/docs/images/chrome/dialog_download.png differ diff --git a/src/HostDialog.ts b/src/HostDialog.ts new file mode 100644 index 0000000..8d0dc93 --- /dev/null +++ b/src/HostDialog.ts @@ -0,0 +1,396 @@ +import browser from 'webextension-polyfill'; +import { Action, AuthType, ScmHost, ServiceWorkerRequest } from './types'; +import { normalizeHost } from './utils'; + +const IP_REGEX = + /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/; +const HOSTNAME_REGEX = + /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/; +const BITBUCKET_BEARER_REGEX = + /^bitbucket\.org\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?$/i; +const BITBUCKET_BASIC_REGEX = /^bitbucket\.org$/i; +const TOKEN_DOC_URLS = { + github: + 'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens', + gitlab: 'https://docs.gitlab.com/security/tokens', + bitbucket: 'https://support.atlassian.com/bitbucket-cloud/docs/access-tokens', +}; + +type Scm = 'github' | 'gitlab' | 'bitbucket'; + +export class HostDialog { + private dialog: HTMLDialogElement; + private title: HTMLElement; + private scmSelect: HTMLSelectElement; + private scmIcon: HTMLImageElement; + private hostInput: HTMLInputElement; + private emailInput: HTMLInputElement; + private tokenInput: HTMLInputElement; + private tokenToggle: HTMLElement; + private saveButton: HTMLButtonElement; + private cancelButton: HTMLButtonElement; + private bitbucketAuth: HTMLDivElement; + private emailRow: HTMLDivElement; + private authRadios: NodeListOf; + private messageSpan: HTMLElement; + + constructor() { + this.dialog = document.getElementById('hostDialog') as HTMLDialogElement; + this.title = document.getElementById('hostDialogTitle')!; + this.scmSelect = this.dialog.querySelector('#dlg-scm') as HTMLSelectElement; + this.scmIcon = this.dialog.querySelector( + '#dlg-scm-icon', + ) as HTMLImageElement; + this.hostInput = this.dialog.querySelector('#dlg-host') as HTMLInputElement; + this.emailInput = this.dialog.querySelector( + '#dlg-email', + ) as HTMLInputElement; + this.tokenInput = this.dialog.querySelector( + '#dlg-token', + ) as HTMLInputElement; + this.tokenToggle = this.dialog.querySelector( + '#dlg-token-toggle', + ) as HTMLElement; + this.saveButton = this.dialog.querySelector( + '#dlg-btn-save', + ) as HTMLButtonElement; + this.cancelButton = this.dialog.querySelector( + '#dlg-btn-cancel', + ) as HTMLButtonElement; + this.bitbucketAuth = this.dialog.querySelector( + '#dlg-bitbucket-auth', + ) as HTMLDivElement; + this.emailRow = this.dialog.querySelector( + '#dlg-email-row', + ) as HTMLDivElement; + this.authRadios = this.dialog.querySelectorAll( + 'input[name="dlg-bitbucket-auth-type"]', + ); + this.messageSpan = this.dialog.querySelector('#dlg-message')!; + } + + private setValid( + input: HTMLElement, + value: boolean | null = true, + tooltip = '', + ) { + if (value === null) { + input.classList.remove('valid', 'invalid'); + } else { + input.classList.add(value ? 'valid' : 'invalid'); + input.classList.remove(value ? 'invalid' : 'valid'); + } + input.title = tooltip; + } + + private setError(input: HTMLElement, message: string | null) { + const err = input.parentElement?.querySelector('.error') as HTMLElement; + if (message) { + this.setValid(input, false); + err.textContent = message; + err.style.visibility = 'visible'; + } else { + err.textContent = ''; + err.style.visibility = 'hidden'; + } + } + + private clearError(input: HTMLElement) { + this.setValid(input, null); + const err = input.parentElement?.querySelector('.error') as HTMLElement; + if (err) { + err.textContent = ''; + err.style.visibility = 'hidden'; + } + } + + private attachLiveClear() { + [this.hostInput, this.tokenInput, this.emailInput].forEach((input) => { + input.addEventListener('input', () => this.clearError(input)); + }); + } + + private getAuthType(): AuthType { + const selectedAuth = Array.from(this.authRadios).find( + (r) => r.checked, + )?.value; + return selectedAuth === 'basic' ? AuthType.Basic : AuthType.Bearer; + } + + private validateField(input: HTMLInputElement): boolean { + if (input.id === 'dlg-host') { + const host = normalizeHost(input.value.trim()); + const scm = this.scmSelect.value as Scm; + if (!host) { + this.setError(input, 'Please enter a host.'); + return false; + } + if (scm === 'bitbucket') { + if ( + !BITBUCKET_BASIC_REGEX.test(host) && + !BITBUCKET_BEARER_REGEX.test(host) + ) { + this.setError(input, 'Invalid host name.'); + return false; + } + const authType = this.getAuthType(); + if (authType === AuthType.Basic) { + // only allow bitbucket.org + if (!BITBUCKET_BASIC_REGEX.test(host)) { + this.setError( + input, + 'For personal access tokens, please use bitbucket.org as host.', + ); + return false; + } + } else { + // require workspace or repository + if (!BITBUCKET_BEARER_REGEX.test(host)) { + this.setError( + input, + 'Please specify a valid workspace or repository.', + ); + return false; + } + } + this.setError(input, null); + this.setValid(input, null); + return true; + } + if (IP_REGEX.test(host)) { + this.setValid(input, null); + return true; + } + if (!HOSTNAME_REGEX.test(host)) { + this.setError(input, 'Invalid host name.'); + return false; + } + this.setValid(input, null); + return true; + } + if (input.id === 'dlg-token') { + const token = input.value.trim(); + if (!token) { + this.setError(input, 'Please enter a token.'); + return false; + } + this.setValid(input, null); + return true; + } + if (input.id === 'dlg-email') { + const email = input.value.trim(); + const scm = this.scmSelect.value as Scm; + const authType = this.getAuthType(); + if (scm !== 'bitbucket' || authType !== AuthType.Basic) { + this.clearError(input); + return true; + } + if (!email) { + this.setError(input, 'Please enter your email address.'); + return false; + } + const simpleEmailRegex = /.+@.+\..+/; + if (!simpleEmailRegex.test(email)) { + this.setError(input, 'Please enter a valid email address.'); + return false; + } + this.setError(input, null); + return true; + } + this.setValid(input, null); + return true; + } + + private validateDialog(): boolean { + const fields: HTMLInputElement[] = [ + this.hostInput, + this.tokenInput, + this.emailInput, + ]; + let allValid = true; + for (const field of fields) { + const ok = this.validateField(field); + if (!ok) allValid = false; + } + return allValid; + } + + private updateDialogVisibility() { + const isBitbucket = this.scmSelect.value === 'bitbucket'; + const dlgHostHint = document.getElementById('dlg-host-hint'); + if (!isBitbucket) { + this.bitbucketAuth.classList.add('hidden'); + this.emailRow.classList.add('hidden'); + dlgHostHint.style.display = 'none'; + return; + } + this.bitbucketAuth.classList.remove('hidden'); + dlgHostHint.style.display = 'block'; + const selectedAuth = Array.from(this.authRadios).find( + (r) => r.checked, + )?.value; + if (selectedAuth === AuthType.Basic) { + this.emailRow.classList.remove('hidden'); + } else { + this.emailRow.classList.add('hidden'); + } + } + + private updateControls() { + const linkWrapper = document.getElementById('dlg-token-learnmore')!; + const link = linkWrapper.querySelector('a')!; + const scm = this.scmSelect.value as Scm; + let url = '#'; + let placeholder = ''; + if (scm === 'github') { + url = TOKEN_DOC_URLS.github; + placeholder = 'e.g. github.com'; + } else if (scm === 'gitlab') { + url = TOKEN_DOC_URLS.gitlab; + placeholder = 'e.g. gitlab.com'; + } else if (scm === 'bitbucket') { + url = TOKEN_DOC_URLS.bitbucket; + placeholder = 'e.g. bitbucket.org'; + } + link.href = url; + this.hostInput.placeholder = placeholder; + } + + private buildScmHost(): ScmHost { + const scm = this.scmSelect.value as Scm; + const host = normalizeHost(this.hostInput.value.trim()); + let token = this.tokenInput.value.trim(); + const email = this.emailInput.value.trim(); + const authType = this.getAuthType(); + if (scm === 'bitbucket' && authType === AuthType.Basic) { + token = `${email}:${token}`; + } + return { + scm, + host, + token, + authType: scm === 'bitbucket' ? authType : undefined, + }; + } + + private async checkHostConnection(scmHost: ScmHost): Promise { + const ok = (await browser.runtime.sendMessage({ + action: Action.checkConnection, + option: { scmHost }, + } as ServiceWorkerRequest)) as boolean; + this.setError( + this.tokenInput, + ok ? '' : 'Authentication failed for this host.', + ); + return ok; + } + + public open(prefill?: ScmHost, editRowId?: string, edit = false) { + this.reset(); + if (prefill) { + this.title.textContent = edit ? 'Edit host' : 'Add host'; + this.scmSelect.value = prefill.scm; + this.scmIcon.src = `icons/${prefill.scm}.svg`; + this.hostInput.value = prefill.host; + let tokenValue = prefill.token ?? ''; + let emailValue = ''; + if (prefill.scm === 'bitbucket') { + const authType = prefill.authType || AuthType.Basic; + this.authRadios.forEach((radio) => { + radio.checked = radio.value === authType; + }); + + if (authType === AuthType.Basic) { + if (tokenValue.includes(':')) { + const [emailPart, ...rest] = tokenValue.split(':'); + emailValue = emailPart; + tokenValue = rest.join(':'); + } else { + emailValue = ''; + } + } + } + this.tokenInput.value = tokenValue; + this.emailInput.value = emailValue; + (this.saveButton as HTMLButtonElement).dataset.editRow = editRowId ?? ''; + } else { + this.title.textContent = 'Add host'; + delete (this.saveButton as HTMLButtonElement).dataset.editRow; + } + this.updateDialogVisibility(); + this.updateControls(); + this.dialog.showModal(); + } + + private reset() { + this.scmSelect.value = 'github'; + this.scmIcon.src = `icons/github.svg`; + this.hostInput.value = ''; + this.emailInput.value = ''; + this.tokenInput.value = ''; + this.authRadios.forEach((radio) => { + radio.checked = radio.value === AuthType.Basic; + }); + this.updateDialogVisibility(); + [this.scmSelect, this.hostInput, this.emailInput, this.tokenInput].forEach( + (elem) => { + this.clearError(elem); + }, + ); + } + + private async onDialogSave( + event: Event, + onSaveCallback: (host: ScmHost, editRowId?: string) => Promise, + ) { + event.preventDefault(); + if (!this.validateDialog()) return; + const scmHost = this.buildScmHost(); + const ok = await this.checkHostConnection(scmHost); + if (!ok) return; + const editRowId = (this.saveButton as HTMLButtonElement).dataset.editRow as + | string + | undefined; + await onSaveCallback(scmHost, editRowId); + this.dialog.close(); + } + + public registerEvents( + onSaveCallback: (host: ScmHost, editRowId?: string) => Promise, + ) { + this.cancelButton.addEventListener('click', () => this.dialog.close()); + this.tokenToggle.addEventListener('click', () => { + if (this.tokenInput.type === 'password') { + this.tokenInput.type = 'text'; + this.tokenToggle.textContent = 'visibility_off'; + } else { + this.tokenInput.type = 'password'; + this.tokenToggle.textContent = 'visibility'; + } + }); + this.scmSelect.addEventListener('change', () => { + this.scmIcon.src = `icons/${this.scmSelect.value}.svg`; + this.updateDialogVisibility(); + this.updateControls(); + }); + this.authRadios.forEach((radio) => { + radio.addEventListener('change', () => { + this.updateDialogVisibility(); + this.clearError(this.hostInput); + }); + }); + this.attachLiveClear(); + this.hostInput.addEventListener('blur', () => + this.validateField(this.hostInput), + ); + this.tokenInput.addEventListener('blur', () => + this.validateField(this.tokenInput), + ); + this.emailInput.addEventListener('blur', () => + this.validateField(this.emailInput), + ); + this.saveButton.addEventListener('click', (event) => + this.onDialogSave(event, onSaveCallback), + ); + } +} diff --git a/src/options.ts b/src/options.ts index 152a003..a2fe5f8 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,6 +1,8 @@ import '../styles/options.scss'; import browser from 'webextension-polyfill'; -import { Action, ScmHost, ServiceWorkerRequest } from './types'; +import { Action, AuthType, ScmHost, ServiceWorkerRequest } from './types'; +import { HostDialog } from './HostDialog'; +import { normalizeHost } from './utils'; const IP_REGEX = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/; @@ -8,240 +10,435 @@ const IP_REGEX = const HOSTNAME_REGEX = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/; -function setMessage(type: 'success' | 'error', message: string) { - const elem = document.getElementById('message'); +const BITBUCKET_BEARER_REGEX = + /^bitbucket\.org\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?$/i; +const BITBUCKET_BASIC_REGEX = /^bitbucket\.org$/i; + +type Scm = 'github' | 'gitlab' | 'bitbucket'; + +const SCM_DISPLAY_NAME: Record = { + github: 'Github', + gitlab: 'Gitlab', + bitbucket: 'Bitbucket Cloud', +}; +function setMessage( + type: 'success' | 'error', + message: string, + dialog: boolean = false, +) { + const elem = document.getElementById(dialog ? 'dlg-message' : 'message'); elem.textContent = message; elem.classList.add(type); elem.classList.remove(type == 'success' ? 'error' : 'success'); } -function setValid( - idOrElem: string | HTMLElement, - value: boolean | null = true, - tooltip = '', -) { - const elem = - typeof idOrElem == 'string' ? document.getElementById(idOrElem) : idOrElem; - if (value === null) { - elem.classList.remove('valid', 'invalid'); - } else { - elem.classList.add(value ? 'valid' : 'invalid'); - elem.classList.remove(value ? 'invalid' : 'valid'); - } - elem.title = tooltip; -} - let rowIdCounter = 0; -function getHostTableRowElement( - rowId: string, - elemId: string, -): HTMLElement | null { - return document.getElementById(rowId).querySelector(`[id=${elemId}]`); +function getHostsTableBody(): HTMLTableSectionElement { + return document.getElementById('hosts').querySelector('tbody'); } -async function checkHost(scmHost: ScmHost, rowId: string): Promise { - const hostElem = getHostTableRowElement(rowId, 'host'); - const tokenElem = getHostTableRowElement(rowId, 'token'); - setValid(hostElem, null); - setValid(tokenElem, null); - - let valid = true; - if ( - !IP_REGEX.test(scmHost.host.trim()) && - !HOSTNAME_REGEX.test(scmHost.host.trim()) - ) { - valid = false; - setValid(hostElem, false, 'Not a valid host name. Example: "github.com"'); - } else { - setValid(hostElem); - } - if (!scmHost.token.trim()) { - valid = false; - setValid(tokenElem, false, 'Please enter a token.'); - } +function getHostRows(): HTMLTableRowElement[] { + return Array.from(getHostsTableBody().children) as HTMLTableRowElement[]; +} - if (valid) { - valid = await browser.runtime.sendMessage({ - action: Action.checkConnection, - option: { - scmHost, - }, - } as ServiceWorkerRequest); +function getRowScmHost(row: HTMLTableRowElement): ScmHost { + const scm = row.dataset.scm as Scm; + const token = row.dataset.token ?? ''; + const hostSpan = row.querySelector('.host-text') as HTMLElement | null; + const host = hostSpan?.textContent ?? ''; + const authTypeRaw = row.dataset.authType; + const authType = + scm === 'bitbucket' && + (authTypeRaw === AuthType.Basic || authTypeRaw === AuthType.Bearer) + ? authTypeRaw + : undefined; - setValid(tokenElem, valid, valid ? null : 'Authentification failed!'); - } - return valid; + return { scm, host, token, authType }; } -function getHostsTableBody(): HTMLTableSectionElement { - return document.getElementById('hosts').querySelector('tbody'); -} -function onAddHost(): void { - const body = getHostsTableBody(); - createHostRow(body, { - host: '', - scm: 'github', - token: null, - }); -} -function onRemoveHost(rowId: string): void { - document.getElementById(rowId).remove(); +function getBitbucketScopeLevel(host: string): number { + const parts = host.split('/').filter(Boolean); + if (parts.length <= 1) return 0; + if (parts.length === 2) return 1; + return 2; } -function buildScmHosts(): ScmHost[] { - const rows = getHostsTableBody().childNodes; - const data: ScmHost[] = []; - rows.forEach((row: HTMLTableRowElement) => { - data.push({ - scm: (row.childNodes[0].childNodes[1] as HTMLInputElement).value as - | 'github' - | 'gitlab', - host: (row.childNodes[1].childNodes[0] as HTMLInputElement).value, - token: (row.childNodes[2].childNodes[0] as HTMLInputElement).value, - }); - }); - return data; +function hostPriorityKey(h: ScmHost): { + scmOrder: number; + scopeLevel: number; + hostLen: number; + host: string; +} { + const scm = h.scm as Scm; + const normHost = normalizeHost(h.host); + + // keep platforms grouped + const scmOrderMap: Record = { + bitbucket: 0, + github: 1, + gitlab: 2, + }; + + const scopeLevel = scm === 'bitbucket' ? getBitbucketScopeLevel(normHost) : 0; + + return { + scmOrder: scmOrderMap[scm], + scopeLevel, + hostLen: normHost.length, + host: normHost, + }; } -async function onCheckHosts(): Promise { - const data = buildScmHosts(); - data.forEach((scmHost, index) => { - const hostRow = getHostsTableBody().childNodes[ - index - ] as HTMLTableRowElement; - checkHost(scmHost, hostRow.id); + +function sortHostsByPriority(hosts: ScmHost[]): ScmHost[] { + return [...hosts].sort((a, b) => { + const ka = hostPriorityKey(a); + const kb = hostPriorityKey(b); + + if (ka.scmOrder !== kb.scmOrder) return ka.scmOrder - kb.scmOrder; + if (ka.scopeLevel !== kb.scopeLevel) return kb.scopeLevel - ka.scopeLevel; + if (ka.hostLen !== kb.hostLen) return kb.hostLen - ka.hostLen; + return ka.host.localeCompare(kb.host); }); } -// Function to save options -async function onSave(): Promise { - await browser.runtime.sendMessage({ - action: Action.saveHosts, - option: { hosts: buildScmHosts() }, - } as ServiceWorkerRequest); - - setMessage('success', 'Options saved!'); - setTimeout(() => setMessage('success', ''), 2000); +function renderHosts(hosts: ScmHost[]) { + const body = getHostsTableBody(); + const table = document.getElementById('hosts'); + const placeholder = document.getElementById('hosts-empty-placeholder'); + body.innerHTML = ''; + rowIdCounter = 0; + if (hosts.length === 0) { + table.style.display = 'none'; + if (placeholder) placeholder.style.display = ''; + } else { + table.style.display = ''; + if (placeholder) placeholder.style.display = 'none'; + hosts.forEach((h) => createHostRow(body, h)); + } } -function createHostRow(body: HTMLTableSectionElement, hostInfo: ScmHost) { +function createHostRow( + body: HTMLTableSectionElement, + hostInfo: ScmHost, +): HTMLTableRowElement { const row = body.insertRow(); const rowId = 'hostrow_' + rowIdCounter.toString(); row.id = rowId; rowIdCounter += 1; - // scm platform + const platformCell = row.insertCell(); const img = document.createElement('img'); - if (hostInfo.scm) img.src = `icons/${hostInfo.scm}.svg`; + img.src = `icons/${hostInfo.scm}.svg`; img.width = 16; img.style.paddingRight = '2px'; img.style.verticalAlign = 'middle'; - const scmSelection = document.createElement('select'); - scmSelection.id = `scm`; - scmSelection.addEventListener('input', () => { - img.src = `icons/${scmSelection.value}.svg`; - }); - const option1 = document.createElement('option'); - option1.value = 'github'; - option1.text = 'Github'; - const option2 = document.createElement('option'); - option2.value = 'gitlab'; - option2.text = 'Gitlab'; - - scmSelection.append(option1, option2); - const imgCell = row.insertCell(); - imgCell.append(img, scmSelection); - scmSelection.value = hostInfo.scm; - imgCell.style.whiteSpace = 'nowrap'; - - // host + const label = document.createElement('span'); + label.textContent = + SCM_DISPLAY_NAME[hostInfo.scm as keyof typeof SCM_DISPLAY_NAME]; + label.style.marginLeft = '4px'; + platformCell.append(img, label); + platformCell.style.whiteSpace = 'nowrap'; + const hostCell = row.insertCell(); - const hostInput = document.createElement('input'); - hostInput.type = 'text'; - hostInput.classList.add('hostinput'); - hostInput.id = `host`; - hostInput.value = hostInfo.host; - hostCell.append(hostInput); - - // token - const tokenCell = row.insertCell(); - tokenCell.style.whiteSpace = 'nowrap'; - const tokenInput = document.createElement('input'); - tokenInput.type = 'password'; - tokenInput.classList.add('tokeninput'); - tokenInput.id = `token`; - - tokenInput.value = hostInfo.token; - - const imgShowPw = document.createElement('span'); - imgShowPw.classList.add('material-symbols-outlined', 'clickable'); - imgShowPw.style.verticalAlign = 'text-top'; - imgShowPw.textContent = 'visibility'; - - imgShowPw.style.paddingLeft = '4px'; - imgShowPw.addEventListener('click', () => { - if (imgShowPw.textContent === 'visibility') { - imgShowPw.textContent = 'visibility_off'; - tokenInput.type = 'text'; - } else { - imgShowPw.textContent = 'visibility'; - tokenInput.type = 'password'; - } - }); - tokenCell.append(tokenInput, imgShowPw); + const hostSpan = document.createElement('span'); + hostSpan.classList.add('host-text'); + hostSpan.textContent = hostInfo.host; + hostCell.append(hostSpan); - // actions const actionCell = row.insertCell(); - const delImg = document.createElement('span'); - delImg.classList.add('material-symbols-outlined', 'clickable'); - delImg.style.verticalAlign = 'text-top'; - delImg.textContent = 'delete'; - delImg.addEventListener('click', () => onRemoveHost(rowId)); - actionCell.append(delImg); + const editIcon = document.createElement('span'); + editIcon.classList.add('material-symbols-outlined', 'clickable'); + editIcon.style.verticalAlign = 'text-top'; + editIcon.textContent = 'edit'; + editIcon.title = 'Edit'; + editIcon.addEventListener('click', () => { + const row = document.getElementById(rowId) as HTMLTableRowElement | null; + if (!row) return; + hostDialog.open(getRowScmHost(row), rowId, true); + }); + + actionCell.append(editIcon); + const delIcon = document.createElement('span'); + delIcon.classList.add('material-symbols-outlined', 'clickable'); + delIcon.style.verticalAlign = 'text-top'; + delIcon.textContent = 'delete'; + delIcon.title = 'Remove'; + delIcon.addEventListener('click', () => onRemoveHost(rowId)); + actionCell.append(delIcon); + + row.dataset.token = hostInfo.token ?? ''; + row.dataset.scm = hostInfo.scm; + row.dataset.authType = + hostInfo.scm === 'bitbucket' && hostInfo.authType ? hostInfo.authType : ''; - row.append(imgCell, hostCell, tokenCell, actionCell); + return row; +} + +function buildScmHosts(): ScmHost[] { + return getHostRows().map(getRowScmHost); } async function updateHosts() { const hosts: ScmHost[] = await browser.runtime.sendMessage({ action: Action.getHosts, } as ServiceWorkerRequest); + renderHosts(sortHostsByPriority(hosts)); +} + +// Function to save settings +async function onSave(): Promise { + const sorted = sortHostsByPriority(buildScmHosts()); + await browser.runtime.sendMessage({ + action: Action.saveHosts, + option: { hosts: sorted }, + } as ServiceWorkerRequest); - const body = getHostsTableBody(); - body.innerHTML = ''; - hosts.forEach((scmHost) => { - createHostRow(body, scmHost); + setMessage('success', 'Settings saved!'); + setTimeout(() => setMessage('success', ''), 2000); +} + +async function onRemoveHost(rowId: string): Promise { + const row = document.getElementById(rowId); + if (!row) return; + const confirmed = window.confirm( + 'Are you sure you want to remove this host?', + ); + + if (!confirmed) return; + row.remove(); + await onSave(); + await updateHosts(); +} + +type FieldErrorMap = Partial>; + +interface ValidationResult { + valid: boolean; + reason?: string; + fieldErrors?: FieldErrorMap; +} + +async function validateHostRow( + row: HTMLTableRowElement, +): Promise { + const scmHost = getRowScmHost(row); + row.classList.remove('valid-host', 'invalid-host'); + + const rawHost = scmHost.host ?? ''; + const normHost = normalizeHost(rawHost); + const token = (scmHost.token ?? '').trim(); + + const fieldErrors: FieldErrorMap = {}; + let valid = true; + let reason: string | undefined; + + if (!normHost) { + valid = false; + reason = 'Host is missing.'; + fieldErrors.host = reason; + } else if (scmHost.scm === 'bitbucket') { + if ( + !BITBUCKET_BASIC_REGEX.test(normHost) && + !BITBUCKET_BEARER_REGEX.test(normHost) + ) { + valid = false; + reason = 'Invalid host format.'; + fieldErrors.host = reason; + } + } else { + if (!IP_REGEX.test(normHost) && !HOSTNAME_REGEX.test(normHost)) { + valid = false; + reason = 'Invalid host format.'; + fieldErrors.host = reason; + } + } + + if (!token) { + valid = false; + reason = 'Access token is missing.'; + fieldErrors.token = reason; + } + + if (valid) { + try { + const ok = (await browser.runtime.sendMessage({ + action: Action.checkConnection, + option: { scmHost: { ...scmHost, host: normHost } }, + } as ServiceWorkerRequest)) as boolean; + + if (!ok) { + valid = false; + reason = 'Authentication failed.'; + fieldErrors.token = reason; + } + } catch (e) { + valid = false; + reason = 'Connection error.'; + fieldErrors.token = reason; + console.error('Error while checking host', scmHost.host, e); + } + } + row.classList.add(valid ? 'valid-host' : 'invalid-host'); + row.title = reason ?? ''; + + delete row.dataset.hostError; + delete row.dataset.tokenError; + + if (fieldErrors.host) row.dataset.hostError = fieldErrors.host; + if (fieldErrors.token) row.dataset.tokenError = fieldErrors.token; + + return { valid, reason, fieldErrors }; +} + +async function onCheckHosts(): Promise { + const rows = getHostRows(); + if (rows.length === 0) { + setMessage('error', 'There are no configured hosts.'); + return; + } + + rows.forEach((row) => { + row.classList.remove('valid-host', 'invalid-host'); + row.title = ''; }); + + const results = await Promise.all( + rows.map(async (row) => { + const result = await validateHostRow(row); + const hostText = getRowScmHost(row).host || '(unknown)'; + return { row, host: hostText, ...result }; + }), + ); + + const failures = results.filter((r) => !r.valid); + if (failures.length === 0) { + setMessage('success', 'All hosts passed validation.'); + return; + } else { + setMessage( + 'error', + 'Validation failed for one or more hosts. See the tooltips in the table for details.', + ); + } } -async function addPendingHosts() { - // detect whether a host was added - let hosts: ScmHost[] = await browser.runtime.sendMessage({ + +async function addPendingHosts(): Promise { + const allHosts: ScmHost[] = await browser.runtime.sendMessage({ action: Action.getHosts, } as ServiceWorkerRequest); - const visibleHosts = buildScmHosts().map((host) => host.host); - hosts = hosts.filter((scmHost) => !visibleHosts.includes(scmHost.host)); - if (hosts.length == 0) return; + + const visible = new Set(); + getHostRows().forEach((row) => { + const { scm, host } = getRowScmHost(row); + visible.add(`${scm}|${normalizeHost(host)}`); + }); + const body = getHostsTableBody(); - hosts.forEach((scmHost) => { - createHostRow(body, scmHost); + allHosts.forEach((h) => { + const key = `${h.scm}|${normalizeHost(h.host)}`; + if (!visible.has(key)) createHostRow(body, h); }); } + +type PendingHost = { + scm: Scm; + host: string; +}; + +async function openPendingHostDialog(): Promise { + const { pendingHost } = (await browser.storage.local.get('pendingHost')) as { + pendingHost?: PendingHost; + }; + + if (!pendingHost) return; + + try { + const targetScm = pendingHost.scm; + const targetHostNormalized = normalizeHost(pendingHost.host); + + const hosts: ScmHost[] = await browser.runtime.sendMessage({ + action: Action.getHosts, + } as ServiceWorkerRequest); + + const match = hosts.find( + (h) => + h.scm === targetScm && normalizeHost(h.host) === targetHostNormalized, + ); + + if (!match) return; + + await updateHosts(); + + const row = getHostRows().find((r) => { + const rh = getRowScmHost(r); + return ( + rh.scm === targetScm && normalizeHost(rh.host) === targetHostNormalized + ); + }); + + hostDialog.open(match, row?.id); + } finally { + await browser.storage.local.remove('pendingHost'); + } +} + +const hostDialog = new HostDialog(); + +hostDialog.registerEvents(async (scmHost, editRowId) => { + const body = getHostsTableBody(); + if (editRowId) { + const row = document.getElementById(editRowId) as HTMLTableRowElement; + if (!row) { + console.error('Edit row not found: ', editRowId); + return; + } + const platformCell = row.cells[0]; + const img = platformCell.querySelector('img') as HTMLImageElement; + const label = platformCell.querySelector('span:last-child') as HTMLElement; + img.src = `icons/${scmHost.scm}.svg`; + label.textContent = + SCM_DISPLAY_NAME[scmHost.scm as keyof typeof SCM_DISPLAY_NAME]; + const hostSpan = row.querySelector('.host-text') as HTMLElement; + hostSpan.textContent = scmHost.host; + row.dataset.token = scmHost.token ?? ''; + row.dataset.scm = scmHost.scm; + row.dataset.authType = + scmHost.scm === 'bitbucket' && scmHost.authType ? scmHost.authType : ''; + row.classList.remove('invalid-host'); + row.classList.add('valid-host'); + } else { + const row = createHostRow(body, scmHost); + row.classList.remove('invalid-host'); + row.classList.add('valid-host'); + } + await onSave(); + await updateHosts(); +}); + async function initUi() { - // Event listener for saveAccessTokenButton click - const saveAccessTokenButton = document.getElementById('saveOptions'); - saveAccessTokenButton.addEventListener('click', onSave); const addHostButton = document.getElementById('addhost'); - addHostButton.addEventListener('click', onAddHost); + addHostButton.addEventListener('click', () => hostDialog.open()); + + const addHostEmptyButton = document.getElementById('addhost-empty'); + if (addHostEmptyButton) { + addHostEmptyButton.addEventListener('click', () => hostDialog.open()); + } const checkHostButton = document.getElementById('checkhosts'); checkHostButton.addEventListener('click', onCheckHosts); document.addEventListener('visibilitychange', async () => { - if (!document.hidden) addPendingHosts(); + if (!document.hidden) { + await addPendingHosts(); + const { pendingHost } = await browser.storage.local.get('pendingHost'); + if (pendingHost) await openPendingHostDialog(); + } }); // init data - updateHosts(); + await updateHosts(); + await openPendingHostDialog(); } initUi(); diff --git a/src/popup.ts b/src/popup.ts index e82177d..1c58472 100644 --- a/src/popup.ts +++ b/src/popup.ts @@ -63,6 +63,12 @@ async function fetchModifiedFiles( function showAddHost(host: string) { showElement('addhost'); document.getElementById('addhost_host').textContent = host; + const scmSelect = document.getElementById('addhost_scm') as HTMLSelectElement; + let scm = 'github'; + if (host == 'bitbucket.org') { + scm = 'bitbucket'; + } + scmSelect.value = scm; } async function onAddHost() { const scmSelect = document.getElementById('addhost_scm') as HTMLSelectElement; @@ -71,7 +77,10 @@ async function onAddHost() { action: Action.addHost, option: { hostInfo: { scm: scmSelect.value, host } }, } as ServiceWorkerRequest); - initData(); + await browser.storage.local.set({ + pendingHost: { scm: scmSelect.value, host }, + }); + await browser.runtime.openOptionsPage(); } // Function to display total changes @@ -318,11 +327,14 @@ async function initData() { return; } showElement('addhost', false); - - displayModifiedFiles(await fetchModifiedFiles(hostInfo)); + showElement('loader', true); + const files = await fetchModifiedFiles(hostInfo); + displayModifiedFiles(files); } catch (error) { console.error(error); displayErrorMessage(error); + } finally { + showElement('loader', false); } } diff --git a/src/scm.ts b/src/scm.ts deleted file mode 100644 index 052dba9..0000000 --- a/src/scm.ts +++ /dev/null @@ -1,749 +0,0 @@ -import browser from 'webextension-polyfill'; -import { HostInfo, ModifiedFile, SUPPORTED_FILES } from './types.ts'; -import { Buffer } from 'buffer'; - -// types for responses objects of github and gitlab and generalized types for common usage -type CommitInfo = { - owner: string; - repo: string; - commitHash: string; -}; - -type PullInfo = { - owner: string; - repo: string; - pullNumber: string; -}; - -type GithubCommitInfo = { - files: GithubChangeFile[]; - parents: { sha: string }[]; - sha: string; -}; - -type GithubPullInfo = { - info: { base: { sha: string }; head: { sha: string } }; - files: GithubChangeFile[]; -}; - -type CommonChange = { - filename: string; - filenameOld: string; - new: boolean; - renamed: boolean; - deleted: boolean; - additions: number; - deletions: number; -}; - -type GithubChangeFile = { - additions: number; - deletions: number; - changes: number; - filename: string; - previous_filename?: string; - patch: string; - sha: string; - status: 'added' | 'renamed' | 'removed'; - blob_url: string; - raw_url: string; - content_url: string; -}; - -type GitlabChange = { - diff: string; - new_path: string; - old_path: string; - a_mode: string; - b_mode: string; - new_file: boolean; - renamed_file: boolean; - deleted_file: boolean; - generated_file: boolean | null; -}; - -export abstract class BaseScmAdapter { - hostInfo: HostInfo; - constructor(hostInfo: HostInfo) { - this.hostInfo = hostInfo; - } - protected abstract getApiUrl(): string; - - protected abstract createHeaders(token: string): { Authorization: string }; - - async test(token: string): Promise { - const url = this.getApiUrl(); - try { - const response = await fetch(url, { headers: this.createHeaders(token) }); - return response.ok; - } catch (error) { - console.error(error); - return false; - } - } - - protected isSupportedFile(filename: string): boolean { - const ext = filename.split('.').pop(); - return SUPPORTED_FILES.includes(ext); - } - - async fetchModifiedFiles( - url: string, - token: string, - ): Promise { - let parsedUrl: URL; - try { - parsedUrl = new URL(url); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (error) { - throw new Error(`Not a valid URL: ${url}`); - } - - const commitInfo = this.testCommit(parsedUrl); - console.debug('test commit:', commitInfo); - if (commitInfo) { - return this.handleCommit(commitInfo, token); - } else { - const pullInfo = this.testPullRequest(parsedUrl); - console.debug('test pull request', pullInfo); - if (pullInfo !== null) { - return this.handlePullRequest(pullInfo, token); - } else { - throw new Error('Not a GitHub commit or pull request page.'); - } - } - } - - protected abstract testCommit(url: URL): CommitInfo | null; - protected abstract testPullRequest(url: URL): PullInfo | null; - protected abstract handleCommit( - commitInfo: CommitInfo, - token: string, - ): Promise; - protected abstract handlePullRequest( - pullInfo: PullInfo, - token: string, - ): Promise; - - private async downloadDummy(filename: string, suffix: string) { - const [basename, fileExtension] = filename - .split('/') - .slice(-1)[0] - .split('.'); - const downloadName = `diff/${basename}/${ - basename + suffix - }.${fileExtension}`; - const mimeType = `text/${ - fileExtension === 'txt' ? 'plain' : fileExtension - }`; - const downloadUrl = `data:${mimeType};charset=utf-8,`; - return this.doDownload(downloadUrl, downloadName); - } - - private async doDownload( - downloadUrl: string, - downloadName: string, - ): Promise { - const downloadId = await browser.downloads.download({ - url: downloadUrl, - filename: downloadName, - conflictAction: 'overwrite', - }); - - if (downloadId === undefined) throw new Error('Failed to start download'); - - return new Promise((resolve, reject) => { - browser.downloads.onChanged.addListener( - async function onChanged(downloadDelta) { - if ( - downloadDelta.id === downloadId && - downloadDelta.state && - downloadDelta.state.current === 'complete' - ) { - const downloadItems = await browser.downloads.search({ - id: downloadId, - }); - - if (downloadItems.length > 0) { - resolve(downloadItems[0].filename); - } else { - reject(new Error('Failed to retrieve download item')); - } - browser.downloads.onChanged.removeListener(onChanged); - browser.downloads.erase({ id: downloadId }); - } - }, - ); - }); - } - - private calcShortHash(sha: string): string { - return sha.substring(0, 8); - } - - private async doDownloadFile( - url: string, - type: 'raw' | 'json', - filename: string, - suffix: string, - token: string, - sha: string, - ): Promise { - const [basename, fileExtension] = filename - .split('/') - .slice(-1)[0] - .split('.'); - const downloadName = `diff/${basename}/${ - basename + suffix - }.${fileExtension}`; - - const mimeType = `text/${ - fileExtension === 'txt' ? 'plain' : fileExtension - }`; - - console.debug(`Download file ${filename} from ${url} as ${downloadName}`); - const response = await fetch(url, { headers: this.createHeaders(token) }); - if (!response.ok) { - throw new Error( - `Failed to fetch file ${filename} for commit ${sha} via ${url}: ${response.statusText}`, - ); - } - let content: Buffer; - let downloadUrl: string; - - const supportsObjectURL = !!URL.createObjectURL; - - if (type == 'json') { - const fileData = await response.json(); - - content = Buffer.from(fileData.content, 'base64'); - downloadUrl = `data:${mimeType};base64,${fileData.content}`; - if (supportsObjectURL) { - const res = await fetch(downloadUrl); - downloadUrl = URL.createObjectURL(await res.blob()); - } - } else if (type == 'raw') { - if (supportsObjectURL) { - downloadUrl = URL.createObjectURL(await response.blob()); - } else { - content = Buffer.from(await (await response.blob()).arrayBuffer()); - downloadUrl = `data:${mimeType};base64,${encodeURIComponent( - content.toString('base64'), - )}`; - } - } else { - throw new Error(`Unknown download type: ${type}`); - } - - try { - return await this.doDownload(downloadUrl, downloadName); - } finally { - if (supportsObjectURL) URL.revokeObjectURL(downloadUrl); - } - } - - async downloadDiff(file: ModifiedFile, token: string) { - let oldFile = ''; - if (!file.new) { - oldFile = await this.doDownloadFile( - file.download.old, - file.download.type, - file.filename, - `.${this.calcShortHash(file.shaOld)}.old`, - token, - file.shaOld, - ); - } else { - oldFile = await this.downloadDummy( - file.filename, - `.${this.calcShortHash(file.shaOld)}.old`, - ); - } - - let newFile = ''; - if (!file.deleted) { - newFile = await this.doDownloadFile( - file.download.new, - file.download.type, - file.filename, - `.${this.calcShortHash(file.shaNew)}.new`, - token, - file.shaNew, - ); - } else { - newFile = await this.downloadDummy( - file.filename, - `.${this.calcShortHash(file.shaNew)}.new`, - ); - } - - const protocolUrl = encodeURI( - `tracetronic://diff?file1=${oldFile}&file2=${newFile}&cleanup=True`, - ); - await browser.tabs.update({ url: protocolUrl }); - } - - async downloadFile(file: ModifiedFile, what: 'old' | 'new', token: string) { - const sha = what == 'old' ? file.shaOld : file.shaNew; - const theFile = await this.doDownloadFile( - file.download[what], - file.download.type, - file.filename, - `.${this.calcShortHash(sha)}.${what}`, - token, - sha, - ); - const protocolUrl = encodeURI(`tracetronic:///${theFile}`); - await browser.tabs.update({ url: protocolUrl }); - } -} - -class Github extends BaseScmAdapter { - protected getApiUrl(): string { - let url = null; - - if (this.hostInfo.host == 'github.com') { - url = 'https://api.github.com'; - } else { - url = `https://${this.hostInfo.host}/api/v3`; - } - return url; - } - protected createHeaders(token: string) { - return { Authorization: `token ${token}` }; - } - - protected testCommit(url: URL): CommitInfo | null { - // e.g., https://github.com/Mscht/PackageDiffTest/commit/fc33321adcf0ff9d697f64d32a6dfe5f5a12903a - const result = /\/(.*?)\/(.*?)\/commit\/([a-z0-9]+)$/.exec(url.pathname); - - if (result) - return { owner: result[1], repo: result[2], commitHash: result[3] }; - return null; - } - - protected testPullRequest(url: URL): PullInfo | null { - // e.g., https://github.com/Mscht/PackageDiffTest/pull/1 - // or https://github.com/Mscht/PackageDiffTest/pull/1/files - const result = /\/(.*?)\/(.*?)\/pull\/(\d+)\/?/.exec(url.pathname); - if (result) - return { owner: result[1], repo: result[2], pullNumber: result[3] }; - return null; - } - - private async fetchPaginated( - url: string, - token: string, - perPage = 100, - ): Promise { - let page = 1; - const allItems: T[] = []; - let itemsOnPage: T[]; - - do { - const pageUrl = `${url}?per_page=${perPage}&page=${page}`; - const response = await fetch(pageUrl, { - headers: this.createHeaders(token), - }); - if (!response.ok) { - throw new Error( - `Failed to retrieve paginated data (page ${page}): ${response.statusText}`, - ); - } - itemsOnPage = await response.json(); - allItems.push(...itemsOnPage); - page++; - } while (itemsOnPage.length === perPage); - - return allItems; - } - - private async getCommitDetails( - commitInfo: CommitInfo, - token: string, - ): Promise { - const commitUrl = `${this.getApiUrl()}/repos/${commitInfo.owner}/${ - commitInfo.repo - }/commits/${commitInfo.commitHash}`; - - const response = await fetch(commitUrl, { - headers: this.createHeaders(token), - }); - - if (!response.ok) { - throw new Error( - `Failed to retrieve commit details: ${response.statusText}`, - ); - } - - return await response.json(); - } - - protected async handleCommit( - commitInfo: CommitInfo, - token: string, - ): Promise { - const commitData = await this.getCommitDetails(commitInfo, token); - - if (!commitData.files || !Array.isArray(commitData.files)) { - throw new Error('Unable to retrieve modified files from commitData.'); - } - - const baseApiUrl = `${this.getApiUrl()}/repos/${commitInfo.owner}/${ - commitInfo.repo - }`; - return commitData.files - .filter((f) => this.isSupportedFile(f.filename)) - .map((file) => { - const filenameOld = file.previous_filename ?? file.filename; - const shaOld = commitData.parents[0]?.sha; - return { - filename: file.filename, - filenameOld, - new: file.status == 'added', - renamed: file.status == 'renamed', - deleted: file.status == 'removed', - additions: file.additions, - deletions: file.deletions, - shaOld, - shaNew: commitData.sha, - download: { - type: 'json' as const, - old: shaOld - ? `${baseApiUrl}/contents/${filenameOld}?ref=${shaOld}` - : null, - new: `${baseApiUrl}/contents/${file.filename}?ref=${commitData.sha}`, - }, - }; - }); - } - - private async getPullDetails( - commitInfo: PullInfo, - token: string, - ): Promise { - const pullUrl = `${this.getApiUrl()}/repos/${commitInfo.owner}/${ - commitInfo.repo - }/pulls/${commitInfo.pullNumber}`; - - const response = await fetch(pullUrl, { - headers: this.createHeaders(token), - }); - - if (!response.ok) { - throw new Error( - `Failed to retrieve pull details: ${response.statusText}`, - ); - } - const info = await response.json(); - - const pullFilesUrl = `${this.getApiUrl()}/repos/${commitInfo.owner}/${ - commitInfo.repo - }/pulls/${commitInfo.pullNumber}/files`; - const allFiles: GithubChangeFile[] = - await this.fetchPaginated(pullFilesUrl, token); - - return { info, files: allFiles }; - } - - protected async handlePullRequest( - pullInfo: PullInfo, - token: string, - ): Promise { - const pullData = await this.getPullDetails(pullInfo, token); - const baseApiUrl = `${this.getApiUrl()}/repos/${pullInfo.owner}/${ - pullInfo.repo - }`; - - return pullData.files - .filter((f) => this.isSupportedFile(f.filename)) - .map((file) => { - const filenameOld = file.previous_filename ?? file.filename; - const shaOld = pullData.info.base.sha; - return { - filename: file.filename, - filenameOld, - new: file.status == 'added', - renamed: file.status == 'renamed', - deleted: file.status == 'removed', - additions: file.additions, - deletions: file.deletions, - shaOld, - shaNew: pullData.info.head.sha, - download: { - type: 'json' as const, - old: `${baseApiUrl}/contents/${filenameOld}?ref=${shaOld}`, - new: `${baseApiUrl}/contents/${file.filename}?ref=${pullData.info.head.sha}`, - }, - }; - }); - } -} - -class Gitlab extends BaseScmAdapter { - protected testCommit(url: URL): CommitInfo | null { - // https://myhost/mygroup/myproject/-/commit/7afcf8cd245c29bb424543cef583947230166ae4e - const result = /\/(.*?)\/(.*?)\/-\/commit\/([a-z0-9]+)$/.exec(url.pathname); - - if (result) - return { owner: result[1], repo: result[2], commitHash: result[3] }; - return null; - } - - protected testPullRequest(url: URL): PullInfo | null { - const result = /\/(.*?)\/(.*?)\/-\/merge_requests\/([0-9]+)\/?/.exec( - url.pathname, - ); - - if (result) - return { owner: result[1], repo: result[2], pullNumber: result[3] }; - return null; - } - - private parseStats(diff: string): { additions: number; deletions: number } { - let additions = 0; - let deletions = 0; - const result = diff.matchAll(/\r?\n([+-])/g); - [...result].forEach((entry) => { - if (entry[1] == '+') { - additions += 1; - } else { - deletions += 1; - } - }); - return { additions, deletions }; - } - - private processChanges(changes: GitlabChange[]): CommonChange[] { - return changes - .filter((change) => this.isSupportedFile(change.new_path)) - .map((change: GitlabChange) => { - const stats = this.parseStats(change.diff); - return { - filename: change.new_path, - filenameOld: change.old_path, - new: change.new_file, - renamed: change.renamed_file, - deleted: change.deleted_file, - ...stats, - }; - }); - } - - private async fetchPaginated( - url: string, - token: string, - perPage = 100, - ): Promise { - let page = 1; - let totalPages = 1; - const allItems: T[] = []; - - do { - const response = await fetch(`${url}?per_page=${perPage}&page=${page}`, { - headers: this.createHeaders(token), - }); - if (!response.ok) { - throw new Error( - `Failed to retrieve paginated data (page ${page}): [${response.status}] ${response.statusText}`, - ); - } - if (page === 1) { - const tp = response.headers.get('x-total-pages'); - totalPages = tp ? parseInt(tp, 10) : 1; - } - const batch: T[] = await response.json(); - allItems.push(...batch); - page++; - } while (page <= totalPages); - - return allItems; - } - - private async getCommitDetails( - commitInfo: CommitInfo, - token: string, - ): Promise<{ - sha: string; - parents: { sha: string }[]; - files: CommonChange[]; - }> { - const namespace = encodeURIComponent( - `${commitInfo.owner}/${commitInfo.repo}`, - ); - const commitUrl = `${this.getApiUrl()}/projects/${namespace}/repository/commits/${commitInfo.commitHash}`; - - const response = await fetch(commitUrl, { - headers: this.createHeaders(token), - }); - - if (!response.ok) { - throw new Error( - `Failed to retrieve commit details: [${response.status}] ${response.statusText}`, - ); - } - const commitData = await response.json(); - - const diffUrl = `${this.getApiUrl()}/projects/${namespace}/repository/commits/${commitInfo.commitHash}/diff`; - const allChanges: GitlabChange[] = await this.fetchPaginated( - diffUrl, - token, - ); - - const files = this.processChanges(allChanges); - const parents = commitData.parent_ids.map((id: string) => ({ sha: id })); - - return { - sha: commitInfo.commitHash, - parents, - files, - }; - } - - protected async handleCommit( - commitInfo: CommitInfo, - token: string, - ): Promise { - const commitData = await this.getCommitDetails(commitInfo, token); - - if (!commitData.files || !Array.isArray(commitData.files)) { - throw new Error('Unable to retrieve modified files from commitData.'); - } - - const namespace = encodeURIComponent( - `${commitInfo.owner}/${commitInfo.repo}`, - ); - const baseApiUrl = `${this.getApiUrl()}/projects/${namespace}/repository/files`; - - // commitData.parents[0] is probably empty if this is the first commit - const shaOld = commitData.parents[0]?.sha || commitData.sha; - const shaNew = commitData.sha; - const modifiedFiles = commitData.files.map((file) => { - return { - filename: file.filename, - filenameOld: file.filenameOld, - new: file.new, - deleted: file.deleted, - renamed: file.renamed, - additions: file.additions, - deletions: file.deletions, - shaOld, - shaNew, - download: { - type: 'raw' as const, - old: `${baseApiUrl}/${file.filenameOld.replace( - /\//g, - '%2f', - )}/raw?ref=${shaOld}`, - new: `${baseApiUrl}/${file.filename.replace(/\//g, '%2f')}/raw?ref=${ - shaNew - }`, - }, - }; - }); - - return modifiedFiles; - } - - private async getPullDetails( - pullInfo: PullInfo, - token: string, - ): Promise<{ - info: { - head: { sha: string }; - base: { sha: string }; - }; - files: CommonChange[]; - }> { - const namespace = encodeURIComponent(`${pullInfo.owner}/${pullInfo.repo}`); - const diffsUrl = `${this.getApiUrl()}/projects/${namespace}/merge_requests/${pullInfo.pullNumber}/diffs`; - const allChanges: GitlabChange[] = await this.fetchPaginated( - diffsUrl, - token, - ); - const files: CommonChange[] = this.processChanges(allChanges); - - const response = await fetch( - `${this.getApiUrl()}/projects/${namespace}/merge_requests/${pullInfo.pullNumber}`, - { headers: this.createHeaders(token) }, - ); - if (!response.ok) { - throw new Error( - `Failed to retrieve merge request details: [${response.status}] ${response.statusText}`, - ); - } - const pullData = await response.json(); - - return { - info: { - head: { sha: pullData.diff_refs.head_sha }, - base: { sha: pullData.diff_refs.base_sha }, - }, - files, - }; - } - - protected async handlePullRequest( - pullInfo: PullInfo, - token: string, - ): Promise { - const pullData = await this.getPullDetails(pullInfo, token); - const namespace = encodeURIComponent(`${pullInfo.owner}/${pullInfo.repo}`); - const baseApiUrl = `${this.getApiUrl()}/projects/${namespace}/repository/files`; - - // pullData.info.base.sha is probably not set if target branch has no commit yet - const shaOld = pullData.info.base.sha || pullData.info.head.sha; - const shaNew = pullData.info.head.sha; - const modifiedFiles = pullData.files.map((file) => { - return { - filename: file.filename, - filenameOld: file.filenameOld, - new: file.new, - deleted: file.deleted, - renamed: file.renamed, - additions: file.additions, - deletions: file.deletions, - shaOld, - shaNew, - download: { - type: 'raw' as const, - old: `${baseApiUrl}/${file.filenameOld.replace( - /\//g, - '%2f', - )}/raw?ref=${shaOld}`, - new: `${baseApiUrl}/${file.filename.replace(/\//g, '%2f')}/raw?ref=${ - shaNew - }`, - }, - }; - }); - - return modifiedFiles; - } - - protected createHeaders(token: string): { Authorization: string } { - return { Authorization: `Bearer ${token}` }; - } - - protected getApiUrl(): string { - return `https://${this.hostInfo.host}/api/v4`; - } - async test(token: string): Promise { - const url = this.getApiUrl() + '/metadata'; - try { - const response = await fetch(url, { headers: this.createHeaders(token) }); - - return ( - response.ok || - (response.status == 403 && - response.headers.get('x-gitlab-meta') != null) - ); - } catch (error) { - console.error(error); - return false; - } - } -} - -// export only the adapter lookup -export const scmAdapters = { github: Github, gitlab: Gitlab }; diff --git a/src/scm/base.ts b/src/scm/base.ts new file mode 100644 index 0000000..26b5e9a --- /dev/null +++ b/src/scm/base.ts @@ -0,0 +1,294 @@ +import browser from 'webextension-polyfill'; +import { HostInfo, ModifiedFile, SUPPORTED_FILES } from '../types.ts'; +import { Buffer } from 'buffer'; + +// types for responses objects of github and gitlab and generalized types for common usage +export type CommitInfo = { + owner: string; + repo: string; + commitHash: string; +}; + +export type PullInfo = { + owner: string; + repo: string; + pullNumber: string; +}; + +export type CommonChange = { + filename: string; + filenameOld: string; + new: boolean; + renamed: boolean; + deleted: boolean; + additions: number; + deletions: number; +}; + +export abstract class BaseScmAdapter { + hostInfo: HostInfo; + constructor(hostInfo: HostInfo) { + this.hostInfo = hostInfo; + } + protected abstract getApiUrl(): string; + + protected abstract createHeaders(token: string): { Authorization: string }; + + async test(token: string): Promise { + const url = this.getApiUrl(); + try { + const response = await fetch(url, { headers: this.createHeaders(token) }); + return response.ok; + } catch (error) { + console.error(error); + return false; + } + } + + protected getHttpErrorMessages(): Record { + return {}; + } + + protected buildHttpError(response: Response, context: string): Error { + const map = this.getHttpErrorMessages(); + const statusMessage = + map[response.status] ?? 'An unknown error occurred.'; + + return new Error(`Failed to retrieve ${context}. ${statusMessage}`); + } + + protected isSupportedFile(filename: string): boolean { + const ext = filename.split('.').pop(); + return SUPPORTED_FILES.includes(ext); + } + + async fetchModifiedFiles( + url: string, + token: string, + ): Promise { + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + throw new Error(`Not a valid URL: ${url}`); + } + + const commitInfo = this.testCommit(parsedUrl); + console.debug('test commit:', commitInfo); + if (commitInfo) { + return this.handleCommit(commitInfo, token); + } else { + const pullInfo = this.testPullRequest(parsedUrl); + console.debug('test pull request', pullInfo); + if (pullInfo !== null) { + return this.handlePullRequest(pullInfo, token); + } else { + throw new Error('Not a commit or pull request page.'); + } + } + } + + protected abstract testCommit(url: URL): CommitInfo | null; + protected abstract testPullRequest(url: URL): PullInfo | null; + protected abstract handleCommit( + commitInfo: CommitInfo, + token: string, + ): Promise; + protected abstract handlePullRequest( + pullInfo: PullInfo, + token: string, + ): Promise; + + private async downloadDummy(filename: string, suffix: string) { + const [basename, fileExtension] = filename + .split('/') + .slice(-1)[0] + .split('.'); + const downloadName = `diff/${basename}/${ + basename + suffix + }.${fileExtension}`; + + const mimeType = `text/${ + fileExtension === 'txt' ? 'plain' : fileExtension + }`; + let downloadUrl: string; + if (typeof URL.createObjectURL === 'function') { + // firefox does not support data URL, use blob instead. + // Firefox applies the file extension based on download name. + const blob = new Blob([''], { type: 'text/plain' }); + downloadUrl = URL.createObjectURL(blob); + } else { + // chrome supports data URL. It applies the file extension based on data URL mime type. + downloadUrl = `data:${mimeType};charset=utf-8,`; + } + + try { + return await this.doDownload(downloadUrl, downloadName); + } finally { + if ( + typeof URL.createObjectURL === 'function' && + downloadUrl.startsWith('blob:') + ) { + URL.revokeObjectURL(downloadUrl); + } + } + } + + private async doDownload( + downloadUrl: string, + downloadName: string, + ): Promise { + const downloadId = await browser.downloads.download({ + url: downloadUrl, + filename: downloadName, + conflictAction: 'overwrite', + }); + + if (downloadId === undefined) throw new Error('Failed to start download'); + + return new Promise((resolve, reject) => { + browser.downloads.onChanged.addListener( + async function onChanged(downloadDelta) { + if ( + downloadDelta.id === downloadId && + downloadDelta.state && + downloadDelta.state.current === 'complete' + ) { + const downloadItems = await browser.downloads.search({ + id: downloadId, + }); + + if (downloadItems.length > 0) { + resolve(downloadItems[0].filename); + } else { + reject(new Error('Failed to retrieve download item')); + } + browser.downloads.onChanged.removeListener(onChanged); + browser.downloads.erase({ id: downloadId }); + } + }, + ); + }); + } + + private calcShortHash(sha: string): string { + return sha.substring(0, 8); + } + + private async doDownloadFile( + url: string, + type: 'raw' | 'json', + filename: string, + suffix: string, + token: string, + sha: string, + ): Promise { + const [basename, fileExtension] = filename + .split('/') + .slice(-1)[0] + .split('.'); + const downloadName = `diff/${basename}/${ + basename + suffix + }.${fileExtension}`; + + const mimeType = `text/${ + fileExtension === 'txt' ? 'plain' : fileExtension + }`; + + console.debug(`Download file ${filename} from ${url} as ${downloadName}`); + const response = await fetch(url, { headers: this.createHeaders(token) }); + if (!response.ok) { + throw new Error( + `Failed to fetch file ${filename} for commit ${sha} via ${url}: ${response.statusText}`, + ); + } + let content: Buffer; + let downloadUrl: string; + + const supportsObjectURL = !!URL.createObjectURL; + + if (type == 'json') { + const fileData = await response.json(); + + content = Buffer.from(fileData.content, 'base64'); + downloadUrl = `data:${mimeType};base64,${fileData.content}`; + if (supportsObjectURL) { + const res = await fetch(downloadUrl); + downloadUrl = URL.createObjectURL(await res.blob()); + } + } else if (type == 'raw') { + if (supportsObjectURL) { + downloadUrl = URL.createObjectURL(await response.blob()); + } else { + content = Buffer.from(await (await response.blob()).arrayBuffer()); + downloadUrl = `data:${mimeType};base64,${encodeURIComponent( + content.toString('base64'), + )}`; + } + } else { + throw new Error(`Unknown download type: ${type}`); + } + + try { + return await this.doDownload(downloadUrl, downloadName); + } finally { + if (supportsObjectURL) URL.revokeObjectURL(downloadUrl); + } + } + + async downloadDiff(file: ModifiedFile, token: string) { + let oldFile = ''; + if (!file.new) { + oldFile = await this.doDownloadFile( + file.download.old, + file.download.type, + file.filename, + `.${this.calcShortHash(file.shaOld)}.old`, + token, + file.shaOld, + ); + } else { + oldFile = await this.downloadDummy( + file.filename, + `.${this.calcShortHash(file.shaOld)}.old`, + ); + } + + let newFile = ''; + if (!file.deleted) { + newFile = await this.doDownloadFile( + file.download.new, + file.download.type, + file.filename, + `.${this.calcShortHash(file.shaNew)}.new`, + token, + file.shaNew, + ); + } else { + newFile = await this.downloadDummy( + file.filename, + `.${this.calcShortHash(file.shaNew)}.new`, + ); + } + + const protocolUrl = encodeURI( + `tracetronic://diff?file1=${oldFile}&file2=${newFile}&cleanup=True`, + ); + await browser.tabs.update({ url: protocolUrl }); + } + + async downloadFile(file: ModifiedFile, what: 'old' | 'new', token: string) { + const sha = what == 'old' ? file.shaOld : file.shaNew; + const theFile = await this.doDownloadFile( + file.download[what], + file.download.type, + file.filename, + `.${this.calcShortHash(sha)}.${what}`, + token, + sha, + ); + const protocolUrl = encodeURI(`tracetronic:///${theFile}`); + await browser.tabs.update({ url: protocolUrl }); + } +} diff --git a/src/scm/bitbucket.ts b/src/scm/bitbucket.ts new file mode 100644 index 0000000..9785fa7 --- /dev/null +++ b/src/scm/bitbucket.ts @@ -0,0 +1,285 @@ +import { AuthType, ModifiedFile } from '../types.ts'; +import { BaseScmAdapter, CommitInfo, CommonChange, PullInfo } from './base.ts'; + +type BitbucketChange = { + status: string; + lines_added: number; + lines_removed: number; + new: { path: string }; + old?: { path: string }; +}; + +export class Bitbucket extends BaseScmAdapter { + private authType: AuthType = AuthType.Basic; + + public setAuthType(type: AuthType): void { + this.authType = type; + } + + protected getApiUrl(): string { + const { hostname } = this.parseHostScope(); + if (hostname !== 'bitbucket.org') { + throw new Error('Bitbucket Cloud only supports bitbucket.org'); + } + return `https://api.${hostname}/2.0`; + } + + protected createHeaders(token: string): { Authorization: string } { + if (this.usesBearerAuth()) { + return { Authorization: `Bearer ${token}` }; + } + return { Authorization: `Basic ${btoa(token)}` }; + } + + private parseHostScope() { + const raw = this.hostInfo.host + .trim() + .replace(/^https?:\/\//, '') + .replace(/\/+$/, ''); + const [hostname, workspace, repo] = raw.split('/'); + return { hostname, workspace, repo }; + } + + private usesBearerAuth(): boolean { + return this.authType === AuthType.Bearer; + } + + async test(token: string): Promise { + const { workspace, repo } = this.parseHostScope(); + const baseUrl = this.getApiUrl(); + + try { + if (!this.usesBearerAuth()) { + if (workspace || repo) return false; + + const response = await fetch(`${baseUrl}/user`, { + headers: this.createHeaders(token), + }); + // 403 means the read:user scope is missing, still a valid token + return response.status === 200 || response.status === 403; + } + + if (!workspace) return false; + if (repo) { + const response = await fetch( + `${baseUrl}/repositories/${workspace}/${repo}`, + { + headers: this.createHeaders(token), + }, + ); + return response.status === 200; + } + + const response = await fetch(`${baseUrl}/repositories/${workspace}`, { + headers: this.createHeaders(token), + }); + return response.status === 200; + } catch (error) { + console.error(error); + return false; + } + } + + protected testCommit(url: URL): CommitInfo | null { + const result = /\/(.*?)\/(.*?)\/commits\/([a-z0-9]+)$/.exec(url.pathname); + + if (result) + return { owner: result[1], repo: result[2], commitHash: result[3] }; + return null; + } + + protected testPullRequest(url: URL): PullInfo | null { + const result = + /^\/([^/]+)\/([^/]+)\/pull-?requests?\/(\d+)(?:\/.*)?$/i.exec( + url.pathname, + ); + + if (result) + return { owner: result[1], repo: result[2], pullNumber: result[3] }; + return null; + } + + private processChanges(changes: BitbucketChange[]): CommonChange[] { + return changes + .filter((change) => this.isSupportedFile(change.new.path)) + .map((change) => { + const isAdded = change.status === 'added'; + const isRemoved = change.status === 'removed'; + const isRenamed = change.status === 'renamed'; + return { + filename: change.new.path, + filenameOld: + isRenamed && change.old ? change.old.path : change.new.path, + new: isAdded, + renamed: isRenamed, + deleted: isRemoved, + additions: change.lines_added, + deletions: change.lines_removed, + }; + }); + } + + protected override getHttpErrorMessages(): Record { + return { + 401: 'Unauthorized: Invalid or missing token.', + 403: 'Forbidden: Your credentials lack one or more required privilege scopes.', + 404: 'Not found: You may not have access to this repository.', + }; + } + + private async fetchPaginated(url: string, token: string): Promise { + let results: T[] = []; + let nextUrl: string | null = url; + while (nextUrl) { + const response = await fetch(nextUrl, { + headers: this.createHeaders(token), + }); + if (!response.ok) { + throw this.buildHttpError(response, 'paginated data'); + } + const data = await response.json(); + results = results.concat(data.values); + nextUrl = data.next || null; + } + return results; + } + + private async getCommitDetails( + commitInfo: CommitInfo, + token: string, + ): Promise<{ + sha: string; + parents: { sha: string }[]; + files: CommonChange[]; + }> { + const namespace = encodeURIComponent( + `${commitInfo.owner}/${commitInfo.repo}`, + ); + const commitUrl = `${this.getApiUrl()}/repositories/${namespace}/commit/${commitInfo.commitHash}`; + const response = await fetch(commitUrl, { + headers: this.createHeaders(token), + }); + + if (!response.ok) { + throw this.buildHttpError(response, 'commit details'); + } + const commitData = await response.json(); + + const diffUrl = `${this.getApiUrl()}/repositories/${namespace}/diffstat/${commitInfo.commitHash}`; + const allChanges: BitbucketChange[] = await this.fetchPaginated( + diffUrl, + token, + ); + + const files = this.processChanges(allChanges); + const parents = Array.isArray(commitData.parents) + ? commitData.parents.map(({ hash }: { hash: string }) => ({ sha: hash })) + : []; + return { + sha: commitInfo.commitHash, + parents, + files, + }; + } + + protected async handleCommit( + commitInfo: CommitInfo, + token: string, + ): Promise { + const commitData = await this.getCommitDetails(commitInfo, token); + + if (!commitData.files || !Array.isArray(commitData.files)) { + throw new Error('Unable to retrieve modified files from commitData.'); + } + + const namespace = encodeURIComponent( + `${commitInfo.owner}/${commitInfo.repo}`, + ); + const baseApiUrl = `${this.getApiUrl()}/repositories/${namespace}`; + + const shaOld = commitData.parents[0]?.sha || commitData.sha; + const shaNew = commitData.sha; + const modifiedFiles = commitData.files.map((file) => { + return { + filename: file.filename, + filenameOld: file.filenameOld, + new: file.new, + deleted: file.deleted, + renamed: file.renamed, + additions: file.additions, + deletions: file.deletions, + shaOld, + shaNew, + download: { + type: 'raw' as const, + old: `${baseApiUrl}/src/${shaOld}/${file.filenameOld.replace(/\//g, '%2f')}`, + new: `${baseApiUrl}/src/${shaNew}/${file.filename.replace(/\//g, '%2f')}`, + }, + }; + }); + + return modifiedFiles; + } + + private async getPullDetails( + pullInfo: PullInfo, + token: string, + ): Promise<{ + info: { head: { sha: string }; base: { sha: string } }; + files: CommonChange[]; + }> { + const namespace = encodeURIComponent(`${pullInfo.owner}/${pullInfo.repo}`); + const prUrl = `${this.getApiUrl()}/repositories/${namespace}/pullrequests/${pullInfo.pullNumber}`; + const response = await fetch(prUrl, { + headers: this.createHeaders(token), + }); + + if (!response.ok) { + throw this.buildHttpError(response, 'pull request details'); + } + const prData = await response.json(); + + const headSha = prData.source.commit.hash; + const baseSha = prData.destination.commit.hash; + + const diffStatUrl = `${this.getApiUrl()}/repositories/${namespace}/pullrequests/${pullInfo.pullNumber}/diffstat`; + const allChanges: BitbucketChange[] = await this.fetchPaginated( + diffStatUrl, + token, + ); + + const files = this.processChanges(allChanges); + return { info: { head: { sha: headSha }, base: { sha: baseSha } }, files }; + } + + protected async handlePullRequest( + pullInfo: PullInfo, + token: string, + ): Promise { + const pullData = await this.getPullDetails(pullInfo, token); + + const shaOld = pullData.info.base.sha || pullData.info.head.sha; + const shaNew = pullData.info.head.sha; + + const namespace = encodeURIComponent(`${pullInfo.owner}/${pullInfo.repo}`); + const baseApiUrl = `${this.getApiUrl()}/repositories/${namespace}`; + + const modifiedFiles = pullData.files.map((file) => ({ + filename: file.filename, + filenameOld: file.filenameOld, + new: file.new, + deleted: file.deleted, + renamed: file.renamed, + additions: file.additions, + deletions: file.deletions, + shaOld, + shaNew, + download: { + type: 'raw' as const, + old: `${baseApiUrl}/src/${shaOld}/${file.filenameOld.replace(/\//g, '%2f')}`, + new: `${baseApiUrl}/src/${shaNew}/${file.filename.replace(/\//g, '%2f')}`, + }, + })); + return modifiedFiles; + } +} diff --git a/src/scm/github.ts b/src/scm/github.ts new file mode 100644 index 0000000..21105d8 --- /dev/null +++ b/src/scm/github.ts @@ -0,0 +1,210 @@ +import { BaseScmAdapter, CommitInfo, PullInfo } from './base.ts'; +import { ModifiedFile } from '../types'; + +type GithubCommitInfo = { + files: GithubChangeFile[]; + parents: { sha: string }[]; + sha: string; +}; + +type GithubPullInfo = { + info: { base: { sha: string }; head: { sha: string } }; + files: GithubChangeFile[]; +}; + +type GithubChangeFile = { + additions: number; + deletions: number; + changes: number; + filename: string; + previous_filename?: string; + patch: string; + sha: string; + status: 'added' | 'renamed' | 'removed'; + blob_url: string; + raw_url: string; + content_url: string; +}; + +export class Github extends BaseScmAdapter { + protected getApiUrl(): string { + let url = null; + + if (this.hostInfo.host == 'github.com') { + url = 'https://api.github.com'; + } else { + url = `https://${this.hostInfo.host}/api/v3`; + } + return url; + } + protected createHeaders(token: string) { + return { Authorization: `token ${token}` }; + } + + protected testCommit(url: URL): CommitInfo | null { + // e.g., https://github.com/Mscht/PackageDiffTest/commit/fc33321adcf0ff9d697f64d32a6dfe5f5a12903a + const result = /\/(.*?)\/(.*?)\/commit\/([a-z0-9]+)$/.exec(url.pathname); + + if (result) + return { owner: result[1], repo: result[2], commitHash: result[3] }; + return null; + } + + protected testPullRequest(url: URL): PullInfo | null { + // e.g., https://github.com/Mscht/PackageDiffTest/pull/1 + // or https://github.com/Mscht/PackageDiffTest/pull/1/files + const result = /\/(.*?)\/(.*?)\/pull\/(\d+)\/?/.exec(url.pathname); + if (result) + return { owner: result[1], repo: result[2], pullNumber: result[3] }; + return null; + } + + protected override getHttpErrorMessages(): Record { + return { + 401: 'Unauthorized: Invalid or missing token.', + 403: 'Forbidden: If you use a fine-grained access token, make sure to give permissions "Content" and "Pull requests".', + 404: 'Not found: You may not have access to this repository.', + }; + } + + private async fetchPaginated( + url: string, + token: string, + perPage = 100, + ): Promise { + let page = 1; + const allItems: T[] = []; + let itemsOnPage: T[]; + + do { + const pageUrl = `${url}?per_page=${perPage}&page=${page}`; + const response = await fetch(pageUrl, { + headers: this.createHeaders(token), + }); + if (!response.ok) { + throw this.buildHttpError(response, `paginated data (page ${page})`); + } + itemsOnPage = await response.json(); + allItems.push(...itemsOnPage); + page++; + } while (itemsOnPage.length === perPage); + + return allItems; + } + + private async getCommitDetails( + commitInfo: CommitInfo, + token: string, + ): Promise { + const commitUrl = `${this.getApiUrl()}/repos/${commitInfo.owner}/${ + commitInfo.repo + }/commits/${commitInfo.commitHash}`; + + const response = await fetch(commitUrl, { + headers: this.createHeaders(token), + }); + + if (!response.ok) { + throw this.buildHttpError(response, 'commit details'); + } + return await response.json(); + } + + protected async handleCommit( + commitInfo: CommitInfo, + token: string, + ): Promise { + const commitData = await this.getCommitDetails(commitInfo, token); + + if (!commitData.files || !Array.isArray(commitData.files)) { + throw new Error('Unable to retrieve modified files from commitData.'); + } + + const baseApiUrl = `${this.getApiUrl()}/repos/${commitInfo.owner}/${ + commitInfo.repo + }`; + return commitData.files + .filter((f) => this.isSupportedFile(f.filename)) + .map((file) => { + const filenameOld = file.previous_filename ?? file.filename; + const shaOld = commitData.parents[0]?.sha; + return { + filename: file.filename, + filenameOld, + new: file.status == 'added', + renamed: file.status == 'renamed', + deleted: file.status == 'removed', + additions: file.additions, + deletions: file.deletions, + shaOld, + shaNew: commitData.sha, + download: { + type: 'json' as const, + old: shaOld + ? `${baseApiUrl}/contents/${filenameOld}?ref=${shaOld}` + : null, + new: `${baseApiUrl}/contents/${file.filename}?ref=${commitData.sha}`, + }, + }; + }); + } + + private async getPullDetails( + commitInfo: PullInfo, + token: string, + ): Promise { + const pullUrl = `${this.getApiUrl()}/repos/${commitInfo.owner}/${ + commitInfo.repo + }/pulls/${commitInfo.pullNumber}`; + + const response = await fetch(pullUrl, { + headers: this.createHeaders(token), + }); + + if (!response.ok) { + throw this.buildHttpError(response, 'pull request details'); + } + const info = await response.json(); + + const pullFilesUrl = `${this.getApiUrl()}/repos/${commitInfo.owner}/${ + commitInfo.repo + }/pulls/${commitInfo.pullNumber}/files`; + const allFiles: GithubChangeFile[] = + await this.fetchPaginated(pullFilesUrl, token); + + return { info, files: allFiles }; + } + + protected async handlePullRequest( + pullInfo: PullInfo, + token: string, + ): Promise { + const pullData = await this.getPullDetails(pullInfo, token); + const baseApiUrl = `${this.getApiUrl()}/repos/${pullInfo.owner}/${ + pullInfo.repo + }`; + + return pullData.files + .filter((f) => this.isSupportedFile(f.filename)) + .map((file) => { + const filenameOld = file.previous_filename ?? file.filename; + const shaOld = pullData.info.base.sha; + return { + filename: file.filename, + filenameOld, + new: file.status == 'added', + renamed: file.status == 'renamed', + deleted: file.status == 'removed', + additions: file.additions, + deletions: file.deletions, + shaOld, + shaNew: pullData.info.head.sha, + download: { + type: 'json' as const, + old: `${baseApiUrl}/contents/${filenameOld}?ref=${shaOld}`, + new: `${baseApiUrl}/contents/${file.filename}?ref=${pullData.info.head.sha}`, + }, + }; + }); + } +} diff --git a/src/scm/gitlab.ts b/src/scm/gitlab.ts new file mode 100644 index 0000000..7449e92 --- /dev/null +++ b/src/scm/gitlab.ts @@ -0,0 +1,281 @@ +import { BaseScmAdapter, CommitInfo, PullInfo, CommonChange } from './base.ts'; +import { ModifiedFile } from '../types'; + +type GitlabChange = { + diff: string; + new_path: string; + old_path: string; + a_mode: string; + b_mode: string; + new_file: boolean; + renamed_file: boolean; + deleted_file: boolean; + generated_file: boolean | null; +}; + +export class Gitlab extends BaseScmAdapter { + protected testCommit(url: URL): CommitInfo | null { + // https://myhost/mygroup/myproject/-/commit/7afcf8cd245c29bb424543cef583947230166ae4e + const result = /\/(.*?)\/(.*?)\/-\/commit\/([a-z0-9]+)$/.exec(url.pathname); + + if (result) + return { owner: result[1], repo: result[2], commitHash: result[3] }; + return null; + } + + protected testPullRequest(url: URL): PullInfo | null { + const result = /\/(.*?)\/(.*?)\/-\/merge_requests\/([0-9]+)\/?/.exec( + url.pathname, + ); + + if (result) + return { owner: result[1], repo: result[2], pullNumber: result[3] }; + return null; + } + + private parseStats(diff: string): { additions: number; deletions: number } { + let additions = 0; + let deletions = 0; + const result = diff.matchAll(/\r?\n([+-])/g); + [...result].forEach((entry) => { + if (entry[1] == '+') { + additions += 1; + } else { + deletions += 1; + } + }); + return { additions, deletions }; + } + + private processChanges(changes: GitlabChange[]): CommonChange[] { + return changes + .filter((change) => this.isSupportedFile(change.new_path)) + .map((change: GitlabChange) => { + const stats = this.parseStats(change.diff); + return { + filename: change.new_path, + filenameOld: change.old_path, + new: change.new_file, + renamed: change.renamed_file, + deleted: change.deleted_file, + ...stats, + }; + }); + } + + protected override getHttpErrorMessages(): Record { + return { + 401: 'Unauthorized: Invalid or missing token.', + 403: 'Forbidden: Insufficient scope. Make sure to give permission "read_api".', + 404: 'Not found: You may not have access to this repository.', + }; + } + + private async fetchPaginated( + url: string, + token: string, + perPage = 100, + ): Promise { + let page = 1; + let totalPages = 1; + const allItems: T[] = []; + + do { + const response = await fetch(`${url}?per_page=${perPage}&page=${page}`, { + headers: this.createHeaders(token), + }); + if (!response.ok) { + throw this.buildHttpError(response, `paginated data (page ${page})`); + } + if (page === 1) { + const tp = response.headers.get('x-total-pages'); + totalPages = tp ? parseInt(tp, 10) : 1; + } + const batch: T[] = await response.json(); + allItems.push(...batch); + page++; + } while (page <= totalPages); + + return allItems; + } + + private async getCommitDetails( + commitInfo: CommitInfo, + token: string, + ): Promise<{ + sha: string; + parents: { sha: string }[]; + files: CommonChange[]; + }> { + const namespace = encodeURIComponent( + `${commitInfo.owner}/${commitInfo.repo}`, + ); + const commitUrl = `${this.getApiUrl()}/projects/${namespace}/repository/commits/${commitInfo.commitHash}`; + + const response = await fetch(commitUrl, { + headers: this.createHeaders(token), + }); + + if (!response.ok) { + throw this.buildHttpError(response, 'commit details'); + } + const commitData = await response.json(); + + const diffUrl = `${this.getApiUrl()}/projects/${namespace}/repository/commits/${commitInfo.commitHash}/diff`; + const allChanges: GitlabChange[] = await this.fetchPaginated( + diffUrl, + token, + ); + + const files = this.processChanges(allChanges); + const parents = commitData.parent_ids.map((id: string) => ({ sha: id })); + + return { + sha: commitInfo.commitHash, + parents, + files, + }; + } + + protected async handleCommit( + commitInfo: CommitInfo, + token: string, + ): Promise { + const commitData = await this.getCommitDetails(commitInfo, token); + + if (!commitData.files || !Array.isArray(commitData.files)) { + throw new Error('Unable to retrieve modified files from commitData.'); + } + + const namespace = encodeURIComponent( + `${commitInfo.owner}/${commitInfo.repo}`, + ); + const baseApiUrl = `${this.getApiUrl()}/projects/${namespace}/repository/files`; + + // commitData.parents[0] is probably empty if this is the first commit + const shaOld = commitData.parents[0]?.sha || commitData.sha; + const shaNew = commitData.sha; + const modifiedFiles = commitData.files.map((file) => { + return { + filename: file.filename, + filenameOld: file.filenameOld, + new: file.new, + deleted: file.deleted, + renamed: file.renamed, + additions: file.additions, + deletions: file.deletions, + shaOld, + shaNew, + download: { + type: 'raw' as const, + old: `${baseApiUrl}/${file.filenameOld.replace( + /\//g, + '%2f', + )}/raw?ref=${shaOld}`, + new: `${baseApiUrl}/${file.filename.replace(/\//g, '%2f')}/raw?ref=${ + shaNew + }`, + }, + }; + }); + + return modifiedFiles; + } + + private async getPullDetails( + pullInfo: PullInfo, + token: string, + ): Promise<{ + info: { + head: { sha: string }; + base: { sha: string }; + }; + files: CommonChange[]; + }> { + const namespace = encodeURIComponent(`${pullInfo.owner}/${pullInfo.repo}`); + const diffsUrl = `${this.getApiUrl()}/projects/${namespace}/merge_requests/${pullInfo.pullNumber}/diffs`; + const allChanges: GitlabChange[] = await this.fetchPaginated( + diffsUrl, + token, + ); + const files: CommonChange[] = this.processChanges(allChanges); + + const response = await fetch( + `${this.getApiUrl()}/projects/${namespace}/merge_requests/${pullInfo.pullNumber}`, + { headers: this.createHeaders(token) }, + ); + if (!response.ok) { + throw this.buildHttpError(response, 'merge request details'); + } + const pullData = await response.json(); + + return { + info: { + head: { sha: pullData.diff_refs.head_sha }, + base: { sha: pullData.diff_refs.base_sha }, + }, + files, + }; + } + + protected async handlePullRequest( + pullInfo: PullInfo, + token: string, + ): Promise { + const pullData = await this.getPullDetails(pullInfo, token); + const namespace = encodeURIComponent(`${pullInfo.owner}/${pullInfo.repo}`); + const baseApiUrl = `${this.getApiUrl()}/projects/${namespace}/repository/files`; + + // pullData.info.base.sha is probably not set if target branch has no commit yet + const shaOld = pullData.info.base.sha || pullData.info.head.sha; + const shaNew = pullData.info.head.sha; + const modifiedFiles = pullData.files.map((file) => { + return { + filename: file.filename, + filenameOld: file.filenameOld, + new: file.new, + deleted: file.deleted, + renamed: file.renamed, + additions: file.additions, + deletions: file.deletions, + shaOld, + shaNew, + download: { + type: 'raw' as const, + old: `${baseApiUrl}/${file.filenameOld.replace( + /\//g, + '%2f', + )}/raw?ref=${shaOld}`, + new: `${baseApiUrl}/${file.filename.replace(/\//g, '%2f')}/raw?ref=${ + shaNew + }`, + }, + }; + }); + + return modifiedFiles; + } + + protected createHeaders(token: string): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; + } + + protected getApiUrl(): string { + return `https://${this.hostInfo.host}/api/v4`; + } + async test(token: string): Promise { + const url = this.getApiUrl() + '/metadata'; + try { + const response = await fetch(url, { headers: this.createHeaders(token) }); + + return ( + response.ok || + (response.status == 403 && + response.headers.get('x-gitlab-meta') != null) + ); + } catch (error) { + console.error(error); + return false; + } + } +} diff --git a/src/scm/index.ts b/src/scm/index.ts new file mode 100644 index 0000000..61da230 --- /dev/null +++ b/src/scm/index.ts @@ -0,0 +1,9 @@ +import { Bitbucket } from './bitbucket.ts'; +import { Github } from './github.ts'; +import { Gitlab } from './gitlab.ts'; + +export const scmAdapters = { + github: Github, + gitlab: Gitlab, + bitbucket: Bitbucket, +}; diff --git a/src/serviceWorker.ts b/src/serviceWorker.ts index a9cc164..124a264 100644 --- a/src/serviceWorker.ts +++ b/src/serviceWorker.ts @@ -1,13 +1,14 @@ import browser from 'webextension-polyfill'; import { Action, + AuthType, ErrorType, HostInfo, ModifiedFile, ScmHost, ServiceWorkerRequest, } from './types'; -import { scmAdapters } from './scm'; +import { scmAdapters } from './scm/index'; browser.runtime.onMessage.addListener(async function ( request: ServiceWorkerRequest, @@ -52,12 +53,53 @@ async function checkTab(): Promise { const currentHost = url.host; const hostList = await getHosts(); - const entry = hostList.find((value) => value.host == currentHost); - if (entry) return { scm: entry.scm, host: currentHost }; + const entry = findScmHostForUrl(url, hostList); + if (entry) return { scm: entry.scm, host: entry.host }; return { scm: null, host: currentHost }; } +function normalizeHostKey(host: string): string { + return host + .trim() + .replace(/^https?:\/\//, '') + .replace(/\/+$/, '') + .toLowerCase(); +} + +function findScmHostForUrl(url: URL, hosts: ScmHost[]): ScmHost | undefined { + const hostname = url.hostname.toLowerCase(); + if (hostname === 'bitbucket.org') { + const path = url.pathname.replace(/^\/+|\/+$/g, ''); + const parts = path.split('/'); + + const candidates: string[] = []; + if (parts.length >= 2) { + candidates.push(`bitbucket.org/${parts[0]}/${parts[1]}`); + candidates.push(`bitbucket.org/${parts[0]}`); + } + + candidates.push('bitbucket.org'); + const normalizedHosts = hosts + .filter((h) => h.scm === 'bitbucket') + .map((h) => ({ host: h, key: normalizeHostKey(h.host) })); + + for (const candidate of candidates) { + const key = normalizeHostKey(candidate); + const match = normalizedHosts.find((x) => x.key === key); + if (match) return match.host; + } + return undefined; + } + + const key = normalizeHostKey(hostname); + const normalizedHosts = hosts.map((h) => ({ + host: h, + key: normalizeHostKey(h.host), + })); + return normalizedHosts.find((x) => x.key === key)?.host; +} + async function getHosts(): Promise { const dataString: Record = (await browser.storage.local.get('hosts')) as Record< @@ -87,37 +129,64 @@ async function addHost(hostInfo: HostInfo): Promise { return true; } -async function getToken(hostInfo: HostInfo): Promise { +function createAdapter(hostInfo: HostInfo, entry?: ScmHost) { + const AdapterClass = scmAdapters[hostInfo.scm!]; + const adapter = new AdapterClass(hostInfo); + + if (hostInfo.scm === 'bitbucket' && entry?.authType) { + (adapter as unknown as { setAuthType: (t: AuthType) => void }).setAuthType( + entry.authType, + ); + } + + return adapter; +} + +async function getHostEntry(hostInfo: HostInfo): Promise { const data = await getHosts(); - const entry = data.find((value) => value.host == hostInfo.host); - if (entry == null) throw new Error(ErrorType.HOST_NOT_FOUND); - return entry.token; + const targetKey = normalizeHostKey(hostInfo.host); + const entry = data.find( + (h) => normalizeHostKey(h.host) === targetKey && h.scm === hostInfo.scm, + ); + if (!entry) throw new Error(ErrorType.HOST_NOT_FOUND); + return entry; } async function checkConnection(scmHost: ScmHost): Promise { - return new scmAdapters[scmHost.scm](scmHost).test(scmHost.token); + if (scmHost.token == null) throw new Error(ErrorType.SCM_NOT_SET); + const hostInfo: HostInfo = { scm: scmHost.scm, host: scmHost.host }; + const adapter = createAdapter(hostInfo, scmHost); + return adapter.test(scmHost.token); } async function fetchModifiedFiles(hostInfo: HostInfo) { if (hostInfo.scm == null) throw new Error(ErrorType.SCM_NOT_SET); - const token = await getToken(hostInfo); - if (token == null) throw new Error(ErrorType.TOKEN_NOT_SET); + const entry = await getHostEntry(hostInfo); + if (entry.token == null) throw new Error(ErrorType.TOKEN_NOT_SET); - return await new scmAdapters[hostInfo.scm](hostInfo).fetchModifiedFiles( - (await getActiveTab()).url, - token, - ); + const adapter = createAdapter(hostInfo, entry); + return adapter.fetchModifiedFiles((await getActiveTab()).url, entry.token); } async function downloadDiff(file: ModifiedFile) { const hostInfo = await checkTab(); - const token = await getToken(hostInfo); - new scmAdapters[hostInfo.scm](hostInfo).downloadDiff(file, token); + if (hostInfo.scm == null) throw new Error(ErrorType.SCM_NOT_SET); + + const entry = await getHostEntry(hostInfo); + if (entry.token == null) throw new Error(ErrorType.TOKEN_NOT_SET); + + const adapter = createAdapter(hostInfo, entry); + return adapter.downloadDiff(file, entry.token); } async function downloadFile(file: ModifiedFile, what: 'old' | 'new') { const hostInfo = await checkTab(); - const token = await getToken(hostInfo); - new scmAdapters[hostInfo.scm](hostInfo).downloadFile(file, what, token); + if (hostInfo.scm == null) throw new Error(ErrorType.SCM_NOT_SET); + + const entry = await getHostEntry(hostInfo); + if (entry.token == null) throw new Error(ErrorType.TOKEN_NOT_SET); + + const adapter = createAdapter(hostInfo, entry); + return adapter.downloadFile(file, what, entry.token); } diff --git a/src/types.ts b/src/types.ts index f830f34..fc7b278 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,15 +1,31 @@ -export const SUPPORTED_DIFF_FILES = ['pkg', 'ta', 'prj', 'xam', 'ppd', 'mask', 'gcd', 'tcf', 'tbc']; +export const SUPPORTED_DIFF_FILES = [ + 'pkg', + 'ta', + 'prj', + 'xam', + 'ppd', + 'mask', + 'gcd', + 'tcf', + 'tbc', +]; export const SUPPORTED_FILES = [...SUPPORTED_DIFF_FILES, 'trf']; export type HostInfo = { - scm: 'gitlab' | 'github' | null; + scm: 'gitlab' | 'github' | 'bitbucket' | null; host: string; }; +export enum AuthType { + Basic = 'basic', + Bearer = 'bearer', +} + export type ScmHost = { - scm: 'gitlab' | 'github'; + scm: 'gitlab' | 'github' | 'bitbucket'; host: string; token: string | null; + authType?: AuthType; }; export enum ErrorType { diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..c0f8642 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,10 @@ +/** + * Normalizes a host string by removing protocol, trailing slashes, and lowercasing. + */ +export function normalizeHost(host: string): string { + return host + .trim() + .replace(/^https?:\/\//, '') + .replace(/\/+$/, '') + .toLowerCase(); +} diff --git a/static/icons/bitbucket.svg b/static/icons/bitbucket.svg new file mode 100644 index 0000000..20fd044 --- /dev/null +++ b/static/icons/bitbucket.svg @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/static/manifest.json b/static/manifest.json index b8c1063..acfef29 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Open with ecu.test diff", - "description": "Extension to open the ecu.test/trace.check diff viewer on GitHub/GitLab.", + "description": "Extension to open the ecu.test/trace.check diff viewer on GitHub/GitLab/Bitbucket Cloud.", "version": "1.1.0.0", "background": {}, "browser_specific_settings": { diff --git a/static/options.html b/static/options.html index 2638d57..608c8eb 100644 --- a/static/options.html +++ b/static/options.html @@ -20,17 +20,25 @@
Hosts
+ - - - @@ -53,9 +60,141 @@
Platform URLToken
@@ -45,7 +53,6 @@ Check settings
- + + +
+
Add host
+
+
+ + + + + + + + + + + + + + + + + +
PlatformURL
+
+ + +
+ +
+ + +
+ + + +
+ Authentication +
+
+ + +
+ + + +
+ + + + visibility + + + + Learn more about access tokens + +
+ + +
+
+
diff --git a/static/popup.html b/static/popup.html index 83a14f9..bd71801 100644 --- a/static/popup.html +++ b/static/popup.html @@ -31,15 +31,15 @@ Enable the extension for the current host?
Add as - +

- -