fix: handle empty customDomain when checking for isInternalLink (#11609)
This PR improves the portal's internal link detection logic to be more robust when handling empty or undefined configuration values. Previously, the code could fail when `customDomain` was empty, causing external links to incorrectly behave as internal links. The fix introduces a new `isSameOrigin` helper function that safely compares URLs using proper URL parsing and origin comparison, gracefully handling edge cases like missing domains, relative paths, and malformed URLs. This ensures external links consistently open in new tabs regardless of portal configuration completeness.
This commit is contained in:
@@ -25,15 +25,56 @@ export const getHeadingsfromTheArticle = () => {
|
||||
return rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts various input formats to URL objects.
|
||||
* Handles URL objects, domain strings, relative paths, and full URLs.
|
||||
* @param {string|URL} input - Input to convert to URL object
|
||||
* @returns {URL|null} URL object or null if input is invalid
|
||||
*/
|
||||
const toURL = input => {
|
||||
if (!input) return null;
|
||||
if (input instanceof URL) return input;
|
||||
|
||||
if (
|
||||
typeof input === 'string' &&
|
||||
!input.includes('://') &&
|
||||
!input.startsWith('/')
|
||||
) {
|
||||
return new URL(`https://${input}`);
|
||||
}
|
||||
|
||||
if (typeof input === 'string' && input.startsWith('/')) {
|
||||
return new URL(input, window.location.origin);
|
||||
}
|
||||
|
||||
return new URL(input);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if two URLs belong to the same host by comparing their normalized URL objects.
|
||||
* Handles various input formats including URL objects, domain strings, relative paths, and full URLs.
|
||||
* Returns false if either URL cannot be parsed or normalized.
|
||||
* @param {string|URL} url1 - First URL to compare
|
||||
* @param {string|URL} url2 - Second URL to compare
|
||||
* @returns {boolean} True if both URLs have the same host, false otherwise
|
||||
*/
|
||||
const isSameHost = (url1, url2) => {
|
||||
try {
|
||||
const urlObj1 = toURL(url1);
|
||||
const urlObj2 = toURL(url2);
|
||||
|
||||
if (!urlObj1 || !urlObj2) return false;
|
||||
|
||||
return urlObj1.hostname === urlObj2.hostname;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const openExternalLinksInNewTab = () => {
|
||||
const { customDomain, hostURL } = window.portalConfig;
|
||||
const isSameHost =
|
||||
window.location.href.includes(customDomain) ||
|
||||
window.location.href.includes(hostURL);
|
||||
|
||||
// Modify external links only on articles page
|
||||
const isOnArticlePage =
|
||||
isSameHost && document.querySelector('#cw-article-content') !== null;
|
||||
document.querySelector('#cw-article-content') !== null;
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
if (!isOnArticlePage) return;
|
||||
@@ -41,10 +82,14 @@ export const openExternalLinksInNewTab = () => {
|
||||
const link = event.target.closest('a');
|
||||
|
||||
if (link) {
|
||||
const currentLocation = window.location.href;
|
||||
const linkHref = link.href;
|
||||
|
||||
// Check against current location and custom domains
|
||||
const isInternalLink =
|
||||
link.hostname === window.location.hostname ||
|
||||
link.href.includes(customDomain) ||
|
||||
link.href.includes(hostURL);
|
||||
isSameHost(linkHref, currentLocation) ||
|
||||
(customDomain && isSameHost(linkHref, customDomain)) ||
|
||||
(hostURL && isSameHost(linkHref, hostURL));
|
||||
|
||||
if (!isInternalLink) {
|
||||
link.target = '_blank';
|
||||
|
||||
@@ -103,7 +103,6 @@ describe('openExternalLinksInNewTab', () => {
|
||||
openExternalLinksInNewTab();
|
||||
|
||||
const link = simulateClick('#external');
|
||||
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toBe('noopener noreferrer');
|
||||
});
|
||||
@@ -139,4 +138,48 @@ describe('openExternalLinksInNewTab', () => {
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it('opens external links in a new tab even if customDomain is empty', () => {
|
||||
window = dom.window;
|
||||
window.portalConfig = {
|
||||
hostURL: 'app.chatwoot.com',
|
||||
};
|
||||
|
||||
global.window = window;
|
||||
|
||||
openExternalLinksInNewTab();
|
||||
|
||||
const link = simulateClick('#external');
|
||||
const internal = simulateClick('#internal');
|
||||
const custom = simulateClick('#custom');
|
||||
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toBe('noopener noreferrer');
|
||||
|
||||
expect(internal.target).not.toBe('_blank');
|
||||
// this will be blank since the configs customDomain is empty
|
||||
// which is a fair expectation
|
||||
expect(custom.target).toBe('_blank');
|
||||
});
|
||||
|
||||
it('opens external links in a new tab even if hostURL is empty', () => {
|
||||
window = dom.window;
|
||||
window.portalConfig = {
|
||||
customDomain: 'custom.domain.com',
|
||||
};
|
||||
|
||||
global.window = window;
|
||||
|
||||
openExternalLinksInNewTab();
|
||||
|
||||
const link = simulateClick('#external');
|
||||
const internal = simulateClick('#internal');
|
||||
const custom = simulateClick('#custom');
|
||||
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toBe('noopener noreferrer');
|
||||
|
||||
expect(internal.target).not.toBe('_blank');
|
||||
expect(custom.target).not.toBe('_blank');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user