diff --git a/package.json b/package.json index 07db454..9fb5303 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "docs:dev": "vitepress dev docs", "docs:preview": "vitepress preview docs", "get-contributors": "npx get-contributors cssnr/auto-auth -f docs/.vitepress/contributors.json", + "prettier": "npm run prettier:write", "prettier:check": "npx prettier --check .", "prettier:write": "npx prettier --write .", "lint": "npx eslint src", diff --git a/src/components/HostModal.vue b/src/components/HostModal.vue index d75ebb5..9cebbb1 100644 --- a/src/components/HostModal.vue +++ b/src/components/HostModal.vue @@ -80,9 +80,9 @@ async function onSubmit() { // existing if (isAdding.value || hostRef.value !== originalHost.value) { - const existing = await Hosts.get(hostRef.value) - debug('existing:', existing) - if (existing) { + const exists = await Hosts.has(hostRef.value) + debug('existing:', exists) + if (exists) { debug('Existing Host:', hostRef.value) hostnameEl.value?.focus() hostnameEl.value?.select() @@ -100,7 +100,7 @@ async function onSubmit() { if (!passRef.value) { debug('No password') passwordEl.value?.focus() - passInvalid.value = `${i18n.t('ui.text.password')} ${i18n.t('ui.text.password')}` + passInvalid.value = `${i18n.t('ui.text.password')} ${i18n.t('ui.text.invalid')}` return } diff --git a/src/entrypoints/auth/App.vue b/src/entrypoints/auth/App.vue index 62e3c6b..9765c1b 100644 --- a/src/entrypoints/auth/App.vue +++ b/src/entrypoints/auth/App.vue @@ -8,7 +8,7 @@ import { openOptions } from '@/utils/extension.ts' import { getSession, saveKeyValue } from '@/utils/options.ts' import { useOptions } from '@/composables/useOptions.ts' import { showToast } from '@/composables/useToast.ts' -import { Hosts } from '@/utils/hosts.ts' +import { Hosts, findBestWildcardMatch } from '@/utils/hosts.ts' import ToastAlerts from '@/components/ToastAlerts.vue' import BackToTop from '@/components/BackToTop.vue' import OptionsOffscreen from '@/components/OptionsOffscreen.vue' @@ -93,6 +93,14 @@ function saveCredsChange(event?: Event) { sessionStorage.setItem(hostRef.value, saveCreds.value ? '1' : '0') } +async function populateFields(credsStr: string) { + const [username, password] = parseCreds(credsStr) + userRef.value = username + await nextTick() + usernameEl.value?.select() + passRef.value = password +} + onMounted(async () => { // NOTE: Copied from VanillaJS... const searchParams = new URLSearchParams(window.location.search) @@ -117,24 +125,20 @@ onMounted(async () => { debug('session:', session) if (creds) { - debug('if creds:', creds) hasSavedCreds.value = true if (creds !== 'ignored') { - const [username, password] = parseCreds(creds) - userRef.value = username - debug('usernameEl.value:', usernameEl.value) - await nextTick() - usernameEl.value?.select() - passRef.value = password + debug('if creds:', creds) + await populateFields(creds) } } else if (hostRef.value in session) { debug('else hostRef.value in session:', hostRef.value) - const [username, password] = parseCreds(session[hostRef.value]) - userRef.value = username - debug('usernameEl.value:', usernameEl.value) - await nextTick() - usernameEl.value?.select() - passRef.value = password + await populateFields(session[hostRef.value]) + } else { + const bestMatch = findBestWildcardMatch(hostRef.value, session) + if (bestMatch) { + debug('session wildcard match:', bestMatch) + await populateFields(bestMatch) + } } const link = document.querySelector('link[rel*="icon"]') diff --git a/src/entrypoints/background/auth.ts b/src/entrypoints/background/auth.ts index 92ab81d..f9a4dd0 100644 --- a/src/entrypoints/background/auth.ts +++ b/src/entrypoints/background/auth.ts @@ -1,6 +1,6 @@ import { parseCreds } from '@/utils/creds.ts' import { getOptions, getSession } from '@/utils/options.ts' -import { Hosts } from '@/utils/hosts.ts' +import { Hosts, findBestWildcardMatch } from '@/utils/hosts.ts' // TODO: Logging @@ -10,7 +10,11 @@ export function onAuthRequired( details: chrome.webRequest.OnAuthRequiredDetails, asyncCallback?: (response: chrome.webRequest.BlockingResponse) => void, ): chrome.webRequest.BlockingResponse | undefined { - processRequest(details, asyncCallback).catch(console.warn) + // TODO: if (!asyncCallback) throw - should be called here... + processRequest(details, asyncCallback).catch((e) => { + console.warn(e) + if (asyncCallback) asyncCallback({}) + }) return undefined // returned so asyncCallback can be called } @@ -64,7 +68,7 @@ async function processRequest( // Check if Request Already Processed if (pendingRequests.includes(details.requestId)) { console.log('%cAlready Processed requestId:', 'color: Orange', details.requestId) - hijackRequest(true) + return hijackRequest(true) } pendingRequests.push(details.requestId) @@ -88,9 +92,11 @@ async function processRequest( const session = await getSession() // console.log('session:', session) - if (url.host in session) { + // Find session creds (exact or wildcard match) + const sessionCreds = session[url.host] ?? findBestWildcardMatch(url.host, session) + if (sessionCreds) { console.log('%cSending Session Creds for:', 'color: SpringGreen', details.requestId) - const [username, password] = parseCreds(session[url.host]) + const [username, password] = parseCreds(sessionCreds) const authCredentials: chrome.webRequest.AuthCredentials = { username, password } // console.log('authCredentials:', authCredentials) return asyncCallback({ authCredentials }) diff --git a/src/entrypoints/content/index.ts b/src/entrypoints/content/index.ts index 1732f6d..a2b87da 100644 --- a/src/entrypoints/content/index.ts +++ b/src/entrypoints/content/index.ts @@ -1,6 +1,6 @@ import { i18n } from '#imports' import { defineContentScript } from 'wxt/utils/define-content-script' -import { Hosts } from '@/utils/hosts.ts' +import { Hosts, findBestWildcardMatch } from '@/utils/hosts.ts' // TODO: Logging @@ -27,33 +27,55 @@ export default defineContentScript({ async function onChanged(changes: Record) { // console.debug('content/index.ts - onChanged:', changes) - const items = changes[url.host[0]] // NOTE: Lazy Typing... in changes - if (!items) return - const oldCreds = items.oldValue?.[url.host] - const newCreds = items.newValue?.[url.host] - if (oldCreds !== newCreds) await processCreds(newCreds) + const exactItems = changes[url.host[0]] // NOTE: Lazy Typing... in changes + const wildcardItems = changes['*'] + + if (!exactItems && !wildcardItems) return + + if (exactItems) { + const oldCreds = exactItems.oldValue?.[url.host] + const newCreds = exactItems.newValue?.[url.host] + if (oldCreds !== newCreds) { + // If exact match was removed, check if a wildcard still covers this host + if (!newCreds) { + const wildcard = findBestWildcardMatch(url.host, await Hosts.all()) + return await processCreds(wildcard) + } + return await processCreds(newCreds) + } + } + + if (wildcardItems) { + const oldWildcard = findBestWildcardMatch(url.host, wildcardItems.oldValue) + const newWildcard = findBestWildcardMatch(url.host, wildcardItems.newValue) + if (oldWildcard !== newWildcard) await processCreds(newWildcard) + } } async function processCreds(creds: any) { // console.debug('processCreds - tabEnabled:', tabEnabled, '- creds:', creds) - if (creds) { - tabEnabled = true - if (creds === 'ignored') { - console.log('%cIgnored - Site is Ignored!', 'color: Gold') - await chrome.runtime.sendMessage({ - badgeText: i18n.t('content.badge.off'), - badgeColor: 'yellow', - }) - } else { - console.log('%cEnabled - Site Credentials Found.', 'color: LimeGreen') - await chrome.runtime.sendMessage({ - badgeText: i18n.t('content.badge.on'), - badgeColor: 'green', - }) + try { + if (creds) { + tabEnabled = true + if (creds === 'ignored') { + console.log('%cIgnored - Site is Ignored!', 'color: Gold') + await chrome.runtime.sendMessage({ + badgeText: i18n.t('content.badge.off'), + badgeColor: 'yellow', + }) + } else { + console.log('%cEnabled - Site Credentials Found.', 'color: LimeGreen') + await chrome.runtime.sendMessage({ + badgeText: i18n.t('content.badge.on'), + badgeColor: 'green', + }) + } + } else if (tabEnabled) { + console.log('%cDisabled - Site Credentials Removed.', 'color: Tomato') + tabEnabled = false + await chrome.runtime.sendMessage({ badgeText: '' }) } - } else if (tabEnabled) { - console.log('%cDisabled - Site Credentials Removed.', 'color: Tomato') - tabEnabled = false - await chrome.runtime.sendMessage({ badgeText: '' }) + } catch { + // extension is reloaded, updated, or the page outlives the background script } } diff --git a/src/entrypoints/popup/App.vue b/src/entrypoints/popup/App.vue index fc719d2..098535f 100644 --- a/src/entrypoints/popup/App.vue +++ b/src/entrypoints/popup/App.vue @@ -22,7 +22,8 @@ debug('width:', width.value) const options = useOptions() -const hostnameRef = ref('') // tab hostname +const tabHost = ref('') // original tab hostname +const hostnameRef = ref('') // matched hostname const usernameRef = ref('') // saved username const savedCreds = ref('') // has credentials @@ -39,6 +40,12 @@ function deleteClick(host: string) { } } +function setMatch(match: { key: string; creds: string } | undefined) { + hostnameRef.value = match?.key ?? '' + savedCreds.value = match?.creds ?? '' + usernameRef.value = match ? parseCreds(match.creds)[0] : '' +} + // DUPLICATION: HostsTable.vue async function deleteHost(host: string) { debug('popup/App.vue - deleteHost:', host) @@ -46,8 +53,8 @@ async function deleteHost(host: string) { // debug('creds:', creds) try { await Hosts.delete(host) - savedCreds.value = '' // NOTE: These 2 lines are only differences - usernameRef.value = '' // NOTE: These 2 lines are only differences + const match = await Hosts.find(hostnameRef.value) + setMatch(match) showToast(`${i18n.t('ui.text.removed')}: ${host}`, 'success') } catch (e) { const message = e instanceof Error ? e.message : i18n.t('import.errorUnknown') @@ -57,9 +64,10 @@ async function deleteHost(host: string) { async function onSubmit(host: string, user: string, pass: string, original?: string) { debug('popup/App.vue - onSubmit:', host, user, pass, original) - await submitHost(host, user, pass, original) - savedCreds.value = `${user}:${pass}` - usernameRef.value = user + const success = await submitHost(host, user, pass, original) + if (!success) return + const match = original !== host ? await Hosts.find(tabHost.value) : { key: host, creds: `${user}:${pass}` } + setMatch(match) } onMounted(async () => { @@ -68,12 +76,10 @@ onMounted(async () => { if (!tab.url) return debug('No URL for Tab - No Access.') const url = new URL(tab.url) debug('url:', url) - hostnameRef.value = url.host - const creds = await Hosts.get(url.host) - debug('creds:', creds) - if (!creds) return debug('No Saved Creds for Host.') - savedCreds.value = creds - usernameRef.value = parseCreds(creds)[0] + tabHost.value = url.host + const match = await Hosts.find(url.host) + debug('match:', match) + setMatch(match) }) diff --git a/src/utils/hosts.ts b/src/utils/hosts.ts index 471dc59..42ef336 100644 --- a/src/utils/hosts.ts +++ b/src/utils/hosts.ts @@ -1,16 +1,29 @@ export type HostsRecord = Record export class Hosts { - static readonly keys: string[] = [...'abcdefghijklmnopqrstuvwxyz0123456789'] + static readonly keys: string[] = [...'*abcdefghijklmnopqrstuvwxyz0123456789'] static async all(): Promise { const sync = await chrome.storage.sync.get(Hosts.keys) return Object.assign({}, ...Object.values(sync)) as HostsRecord } + static async has(host: string): Promise { + const sync = await Hosts.#getSync(host) + return host in sync + } + static async get(host: string): Promise { + const result = await Hosts.find(host) + return result?.creds + } + + static async find(host: string): Promise<{ key: string; creds: string } | undefined> { const sync = await Hosts.#getSync(host) - return sync[host] + const exact = sync[host] + if (exact) return { key: host, creds: exact } + + return findBestWildcard(host, await Hosts.all()) } static async set(host: string, creds: string): Promise { @@ -26,10 +39,10 @@ export class Hosts { } static async edit(old: string, host: string, creds: string): Promise { + await this.set(host, creds) if (old !== host) { await this.delete(old) } - await this.set(host, creds) } static async update(hosts: HostsRecord): Promise { @@ -49,20 +62,79 @@ export class Hosts { } } -// Above Code is Original - New Code Below - // NOTE: Moved from components/HostModal.vue and exported export function validateHostname(hostname: string): string | undefined { - // console.log('validateHostname:', hostname) - try { - let value = hostname - // console.log('value1:', value) - if (!value.includes('://')) value = `https://${value}` - // console.log('value2:', value) - const url = new URL(value) - // console.log(`url.hostname: "${url.hostname}"`, url) - return url.hostname - } catch { - // invalid hostname + const value = hostname.toLowerCase().trim() + + const [hostPart, portPart] = parseHostPort(value) + + if (portPart !== undefined && portPart !== '*' && !/^\d+$/.test(portPart)) { + return undefined + } + + const segments = hostPart.split('.') + if (segments.length === 0 || segments.includes('')) return undefined + for (const segment of segments) { + if (segment === '*') continue + if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(segment)) return undefined } + + return portPart !== undefined ? `${hostPart}:${portPart}` : hostPart +} + +export function matchesWildcard(host: string, pattern: string): boolean { + const [hostName, hostPort] = parseHostPort(host) + const [patternName, patternPort] = parseHostPort(pattern) + + if (patternPort !== undefined && patternPort !== '*' && patternPort !== hostPort) { + return false + } + + const hostParts = hostName.split('.') + const patternParts = patternName.split('.') + if (patternParts.length !== hostParts.length) return false + + return patternParts.every((part, i) => + part === '*' ? (hostParts[i]?.length ?? 0) > 0 : part === hostParts[i], + ) +} + +export function findBestWildcardMatch( + host: string, + patterns: Record | undefined, +): string | undefined { + return findBestWildcard(host, patterns)?.creds +} + +function findBestWildcard( + host: string, + patterns: Record | undefined, +): { key: string; creds: string } | undefined { + if (!patterns) return undefined + let bestKey: string | undefined + let bestCreds: string | undefined + let bestSpecificity = Infinity + for (const [pattern, creds] of Object.entries(patterns)) { + if (!pattern.includes('*')) continue + if (matchesWildcard(host, pattern)) { + const specificity = countWildcards(pattern) + if (specificity < bestSpecificity) { + bestSpecificity = specificity + bestKey = pattern + bestCreds = creds + } + } + } + return bestKey ? { key: bestKey, creds: bestCreds! } : undefined +} + +function parseHostPort(value: string): [string, string | undefined] { + const colon = value.indexOf(':') + return colon === -1 + ? [value, undefined] + : [value.slice(0, colon), value.slice(colon + 1)] +} + +function countWildcards(pattern: string): number { + return (pattern.match(/\*/g) || []).length } diff --git a/src/utils/index.ts b/src/utils/index.ts index 02f6e30..5078356 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -25,7 +25,7 @@ export async function submitHost( user: string, pass: string, original?: string, -) { +): Promise { // console.debug('submitHost:', host, user, pass, original) try { // NOTE: Update Hosts.set to handle this logic... @@ -35,8 +35,10 @@ export async function submitHost( await Hosts.set(host, `${user}:${pass}`) } showToast(`${i18n.t('ui.action.addEdit')}: ${host}`, 'success') + return true } catch (e) { const message = e instanceof Error ? e.message : i18n.t('import.errorUnknown') showToast(`${i18n.t('ui.text.addEditError')}: ${message}`, 'danger') + return false } } diff --git a/tests/test-validate-hostname.ts b/tests/test-validate-hostname.ts new file mode 100644 index 0000000..d3c2a51 --- /dev/null +++ b/tests/test-validate-hostname.ts @@ -0,0 +1,56 @@ +import { validateHostname } from '@/utils/hosts.ts' + +const validTests: [string, string][] = [ + ['example.com', 'example.com'], + ['sub.example.com', 'sub.example.com'], + ['a.b.example.com', 'a.b.example.com'], + ['exa-mple.com', 'exa-mple.com'], + ['example.com:8080', 'example.com:8080'], + ['sub.example.com:443', 'sub.example.com:443'], + ['9gag.com', '9gag.com'], + ['localhost', 'localhost'], + ['Example.COM', 'example.com'], + [' Example.COM ', 'example.com'], +] + +const invalidTests: string[] = [ + '.example.com', + '-example.com', + '_example.com', + '!example.com', + '@example.com', + '#example.com', + '$example.com', + '%example.com', + '^example.com', + '&example.com', + '(example.com', + '+example.com', + '=example.com', + '[example.com', + '{example.com', + '|example.com', + ':example.com', + ';example.com', + ',example.com', + '~example.com', + '`example.com', + 'example.com:abc', + 'example..com', + '.', + '-', +] + +console.log('Valid hostnames:') +for (const [input, expected] of validTests) { + const result = validateHostname(input) + const status = result === expected ? '' : `⛔ FAIL (got ${result})` + console.log(` ${input.padEnd(20)} -> ${expected} ${status}`) +} + +console.log('\nInvalid hostnames:') +for (const input of invalidTests) { + const result = validateHostname(input) + const status = result === undefined ? '' : `⛔ FAIL (got ${result})` + console.log(` ${input.padEnd(20)} -> undefined ${status}`) +} diff --git a/tests/test-wildcard.ts b/tests/test-wildcard.ts new file mode 100644 index 0000000..f695763 --- /dev/null +++ b/tests/test-wildcard.ts @@ -0,0 +1,23 @@ +import { matchesWildcard } from '@/utils/hosts.ts' + +const tests: [string, string, boolean][] = [ + ['sub.example.com', '*.example.com', true], + ['a.b.example.com', '*.example.com', false], + ['a.b.example.com', '*.*.com', false], + ['example.com', '*.example.com', false], + ['example.com:8080', 'example.com:*', true], + ['example.com', 'example.com:*', true], + ['example.com:8080', 'example.com:8080', true], + ['example.com:8080', 'example.com:9090', false], + ['sub.example.com', '*.*.com', true], + ['example.com', 'example.com', true], + ['example.com', '*', false], + ['sub.example.com:8080', '*.example.com:*', true], + ['sub.example.com:8080', '*.example.com:9090', false], +] + +for (const [host, pattern, expected] of tests) { + const result = matchesWildcard(host, pattern) + const status = result === expected ? '' : '⛔ FAIL ⛔' + console.log(`${pattern.padEnd(19)} ${expected ? '✅' : '❌'} ${host} ${status}`) +}