v3 · 37 files

ProjectElly/lib/ellyUrlMatch.js 3.7 KB Raw
/**
 * Host-aware URL pattern matching for Elly block/allow lists.
 * Prefer this over bare `url.includes(pattern)` for http(s) hosts.
 */

/**
 * @param {string} url
 * @returns {string}
 */
export function extractHostname(url) {
    try {
        return new URL(url).hostname.toLowerCase();
    } catch {
        return '';
    }
}

/**
 * Strip leading www. for domain comparisons.
 * @param {string} host
 * @returns {string}
 */
export function bareHostname(host) {
    return String(host || '').toLowerCase().replace(/^www\./, '');
}

/**
 * True when host matches domain or is a subdomain of domain.
 * @param {string} host
 * @param {string} domain
 * @returns {boolean}
 */
export function hostMatchesDomain(host, domain) {
    const h = bareHostname(host);
    const d = bareHostname(domain);
    if (!h || !d) {
        return false;
    }
    return h === d || h.endsWith(`.${d}`);
}

/**
 * Match a single block/allow pattern against a URL.
 * Supports:
 * - Exact host / domain (e.g. example.com, www.example.com)
 * - `*.example.com` → host === example.com OR host.endsWith('.example.com')
 * - Scheme patterns like `chrome://extensions` via startsWith (careful, not bare includes for hosts)
 * - Pathful https patterns when pattern includes `/`
 * @param {string} url
 * @param {string} pattern
 * @returns {boolean}
 */
export function matchesUrlPattern(url, pattern) {
    if (!url || !pattern) {
        return false;
    }
    const urlStr = String(url);
    const patternStr = String(pattern).trim();
    if (!patternStr) {
        return false;
    }

    const lowerUrl = urlStr.toLowerCase();
    const lowerPat = patternStr.toLowerCase();

    // Internal / scheme-prefixed patterns (chrome://, opera://, edge://, about:)
    if (
        lowerPat.startsWith('chrome://')
        || lowerPat.startsWith('opera://')
        || lowerPat.startsWith('edge://')
        || lowerPat.startsWith('about:')
    ) {
        return lowerUrl.startsWith(lowerPat) || lowerUrl.includes(lowerPat);
    }

    // Wildcard host patterns: *.example.com
    if (patternStr.startsWith('*.')) {
        const domain = patternStr.slice(2).toLowerCase().replace(/\/.*$/, '');
        try {
            const host = extractHostname(urlStr);
            if (!host) {
                return false;
            }
            return hostMatchesDomain(host, domain);
        } catch {
            return false;
        }
    }

    // Full URL or path-containing pattern
    if (patternStr.includes('://') || patternStr.includes('/')) {
        if (lowerUrl.startsWith(lowerPat) || lowerUrl.includes(lowerPat)) {
            return true;
        }
        // Fall through to host match on the hostname portion when parseable
    }

    // Host / domain pattern (no bare url.includes for google.com-style hosts)
    try {
        const host = extractHostname(urlStr);
        if (!host) {
            return lowerUrl.includes(lowerPat);
        }
        let domainPart = lowerPat;
        if (domainPart.includes('://')) {
            try {
                domainPart = new URL(patternStr).hostname.toLowerCase();
            } catch {
                domainPart = lowerPat.replace(/^https?:\/\//, '').split('/')[0];
            }
        } else if (domainPart.includes('/')) {
            domainPart = domainPart.split('/')[0];
        }
        domainPart = domainPart.replace(/^\*\./, '');
        return hostMatchesDomain(host, domainPart);
    } catch {
        return false;
    }
}

/**
 * @param {string} url
 * @param {string[] | null | undefined} list
 * @returns {boolean}
 */
export function isUrlInList(url, list) {
    if (!url || !list || !Array.isArray(list)) {
        return false;
    }
    return list.some((pattern) => matchesUrlPattern(url, pattern));
}