Compare commits

...
Author SHA1 Message Date
Charles Bochet 48f8bb6b6a fix: prettier formatting on normalizeUrlOrigin 2026-03-20 16:29:12 +01:00
Charles Bochet 8c5f256f49 fix: simplify normalizeUrlOrigin to use standard URL API and fix lint
Use URL() properties directly instead of manual string parsing.
Remove real customer name from test data. Fix duplicate imports and
prettier formatting.
2026-03-20 16:21:11 +01:00
Charles Bochet 53fb2bef12 Fix 2026-03-20 15:46:31 +01:00
DAL\Administrator 9bdde8b793 fix(links): preserve percent-encoded URLs during normalization 2026-03-20 15:44:00 +08:00
23 changed files with 182 additions and 129 deletions
@@ -1,14 +0,0 @@
import { normalizeUrl } from '@/navigation-menu-item/display/link/utils/normalizeUrl';
describe('normalizeUrl', () => {
it('should leave url unchanged when it has protocol, otherwise prepend https', () => {
expect(normalizeUrl('https://example.com')).toBe('https://example.com');
expect(normalizeUrl('example.com')).toBe('https://example.com');
expect(normalizeUrl(' example.com ')).toBe('https://example.com');
});
it('should return empty string for empty or whitespace input', () => {
expect(normalizeUrl('')).toBe('');
expect(normalizeUrl(' ')).toBe('');
});
});
@@ -1,11 +0,0 @@
export const normalizeUrl = (url: string) => {
const trimmedUrl = url.trim();
if (trimmedUrl === '') {
return trimmedUrl;
}
return trimmedUrl.startsWith('http://') || trimmedUrl.startsWith('https://')
? trimmedUrl
: `https://${trimmedUrl}`;
};
@@ -1,12 +1,11 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorLink';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { computeInsertIndexAndPosition } from '@/navigation-menu-item/common/utils/computeInsertIndexAndPosition';
import { normalizeUrl } from '@/navigation-menu-item/display/link/utils/normalizeUrl';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const useAddLinkToNavigationMenuDraft = () => {
@@ -1,7 +1,7 @@
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { getAbsoluteUrl } from 'twenty-shared/utils';
import { ensureAbsoluteUrl } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { extractDomainFromUrl } from '@/navigation-menu-item/display/link/utils/extractDomainFromUrl';
@@ -45,7 +45,7 @@ export const SidePanelEditLinkItemView = ({
const currentName = selectedItem.name ?? defaultLabel;
const currentDomain = selectedItem.link
? extractDomainFromUrl(getAbsoluteUrl(selectedItem.link))
? extractDomainFromUrl(ensureAbsoluteUrl(selectedItem.link))
: undefined;
const canAutoUpdateName =
currentName === defaultLabel ||
@@ -57,7 +57,7 @@ export const SidePanelEditLinkItemView = ({
if (!canAutoUpdateName) return;
const trimmed = value.trim();
if (!isNonEmptyString(trimmed)) return;
const domain = extractDomainFromUrl(getAbsoluteUrl(trimmed));
const domain = extractDomainFromUrl(ensureAbsoluteUrl(trimmed));
if (domain !== undefined) {
setLastAutoSetName(domain);
onUpdateLink(selectedItem.id, { name: domain });
@@ -67,7 +67,7 @@ export const SidePanelEditLinkItemView = ({
const handleUrlBlur = (event: React.FocusEvent<HTMLInputElement>) => {
const value = event.target.value.trim();
if (isNonEmptyString(value)) {
onUpdateLink(selectedItem.id, { link: getAbsoluteUrl(value) });
onUpdateLink(selectedItem.id, { link: ensureAbsoluteUrl(value) });
setUrlEditInput('');
}
};
@@ -11,7 +11,7 @@ import { useRecordFieldValue } from '@/object-record/record-store/hooks/useRecor
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { FieldMetadataSettingsOnClickAction } from 'twenty-shared/types';
import { getAbsoluteUrl, isDefined } from 'twenty-shared/utils';
import { ensureAbsoluteUrl, isDefined } from 'twenty-shared/utils';
import { IconArrowUpRight, IconCopy } from 'twenty-ui/display';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
@@ -69,7 +69,7 @@ export const useGetSecondaryRecordTableCellButton = () => {
if (isFieldLinks(fieldDefinition)) {
const url = (fieldValue as FieldLinksValue).primaryLinkUrl ?? '';
openLinkOnClick = () => {
window.open(getAbsoluteUrl(url), '_blank');
window.open(ensureAbsoluteUrl(url), '_blank');
};
copyOnClick = () => {
copyToClipboard(url, t`Link copied to clipboard`);
@@ -12,7 +12,7 @@ import {
assertUnreachable,
isDefined,
isEmptyObject,
lowercaseUrlOriginAndRemoveTrailingSlash,
normalizeUrlOrigin,
} from 'twenty-shared/utils';
import { z } from 'zod';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
@@ -185,7 +185,7 @@ export const buildRecordFromImportedStructuredRow = ({
},
[FieldMetadataType.LINKS]: {
primaryLinkLabel: castToString,
primaryLinkUrl: lowercaseUrlOriginAndRemoveTrailingSlash,
primaryLinkUrl: normalizeUrlOrigin,
secondaryLinks: linkArrayJSONSchema.parse,
},
@@ -13,7 +13,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
import {
getUniqueConstraintsFields,
isDefined,
lowercaseUrlOriginAndRemoveTrailingSlash,
normalizeUrlOrigin,
} from 'twenty-shared/utils';
type Column = {
@@ -104,9 +104,7 @@ const getUniqueValues = (
.primaryLinkUrl,
)
) {
return lowercaseUrlOriginAndRemoveTrailingSlash(
row?.[columnName]?.toString().trim() || '',
);
return normalizeUrlOrigin(row?.[columnName]?.toString().trim() || '');
}
return row?.[columnName]?.toString().trim().toLowerCase();
@@ -75,5 +75,33 @@ describe('transformLinksValue', () => {
expect(transformLinksValue(input)).toEqual(expected);
});
it('should preserve percent-encoded payloads when normalizing imported URLs', () => {
const input = {
primaryLinkUrl:
'https://www.google.com/maps/place/Birdie+-+Eventlocation/data=!4m7!3m6!1s0x479e7674e1702985:0xe482992505cb1ba4!8m2!3d48.1584971!4d11.5538261!16s%2Fg%2F1ptwh8096!19sChIJhSlw4XR2nkcRpBvLBSWZguQ?authuser=0&hl=en&rclk=1',
primaryLinkLabel: 'Birdie',
secondaryLinks: JSON.stringify([
{
url: 'https://example.com/test%2520name',
label: 'Encoded secondary link',
},
]),
};
const expected = {
primaryLinkUrl:
'https://www.google.com/maps/place/Birdie+-+Eventlocation/data=!4m7!3m6!1s0x479e7674e1702985:0xe482992505cb1ba4!8m2!3d48.1584971!4d11.5538261!16s%2Fg%2F1ptwh8096!19sChIJhSlw4XR2nkcRpBvLBSWZguQ?authuser=0&hl=en&rclk=1',
primaryLinkLabel: 'Birdie',
secondaryLinks: JSON.stringify([
{
url: 'https://example.com/test%2520name',
label: 'Encoded secondary link',
},
]),
};
expect(transformLinksValue(input)).toEqual(expected);
});
});
});
@@ -1,11 +1,7 @@
import { isNonEmptyString } from '@sniptt/guards';
import isEmpty from 'lodash.isempty';
import { type LinkMetadataNullable } from 'twenty-shared/types';
import {
isDefined,
lowercaseUrlOriginAndRemoveTrailingSlash,
parseJson,
} from 'twenty-shared/utils';
import { isDefined, normalizeUrlOrigin, parseJson } from 'twenty-shared/utils';
import { removeEmptyLinks } from 'src/engine/core-modules/record-transformer/utils/remove-empty-links';
@@ -44,15 +40,13 @@ export const transformLinksValue = (
const processedSecondaryLinks = secondaryLinks?.map((link) => ({
...link,
url: isDefined(link.url)
? lowercaseUrlOriginAndRemoveTrailingSlash(link.url)
: link.url,
url: isDefined(link.url) ? normalizeUrlOrigin(link.url) : link.url,
}));
return {
...value,
primaryLinkUrl: isDefined(primaryLinkUrl)
? lowercaseUrlOriginAndRemoveTrailingSlash(primaryLinkUrl)
? normalizeUrlOrigin(primaryLinkUrl)
: primaryLinkUrl,
primaryLinkLabel,
secondaryLinks: isEmpty(processedSecondaryLinks)
@@ -1,6 +1,6 @@
import crypto from 'crypto';
import { getAbsoluteUrl } from 'twenty-shared/utils';
import { ensureAbsoluteUrl } from 'twenty-shared/utils';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { WEBHOOK_RESPONSE_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/webhook/webhook-response';
@@ -79,7 +79,7 @@ export class CallWebhookJob {
);
const response = await axiosClient.post(
getAbsoluteUrl(data.targetUrl),
ensureAbsoluteUrl(data.targetUrl),
payloadWithoutSecret,
{
headers,
@@ -7,10 +7,7 @@ import {
type ConnectedAccountProvider,
type FieldActorSource,
} from 'twenty-shared/types';
import {
isDefined,
lowercaseUrlOriginAndRemoveTrailingSlash,
} from 'twenty-shared/utils';
import { isDefined, normalizeUrlOrigin } from 'twenty-shared/utils';
import { type DeepPartial, ILike } from 'typeorm';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
@@ -71,7 +68,7 @@ export class CreateCompanyService {
const companiesWithoutTrailingSlash = companies.map((company) => ({
...company,
domainName: company.domainName
? lowercaseUrlOriginAndRemoveTrailingSlash(company.domainName)
? normalizeUrlOrigin(company.domainName)
: undefined,
}));
+3 -2
View File
@@ -180,12 +180,13 @@ export { isRecordGqlOperationSignature } from './typeguard/isRecordGqlOperationS
export { throwIfNotDefined } from './typeguard/throwIfNotDefined';
export { absoluteUrlSchema } from './url/absoluteUrlSchema';
export { buildSignedPath } from './url/buildSignedPath';
export { getAbsoluteUrl } from './url/getAbsoluteUrl';
export { ensureAbsoluteUrl } from './url/ensureAbsoluteUrl';
export { getAbsoluteUrlOrThrow } from './url/getAbsoluteUrlOrThrow';
export { getUrlHostnameOrThrow } from './url/getUrlHostnameOrThrow';
export { isValidHostname } from './url/isValidHostname';
export { isValidUrl } from './url/isValidUrl';
export { lowercaseUrlOriginAndRemoveTrailingSlash } from './url/lowercaseUrlOriginAndRemoveTrailingSlash';
export { normalizeUrl } from './url/normalizeUrl';
export { normalizeUrlOrigin } from './url/normalizeUrlOrigin';
export { safeDecodeURIComponent } from './url/safeDecodeURIComponent';
export { uuidToBase36 } from './uuidToBase36';
export { assertIsDefinedOrThrow } from './validation/assertIsDefinedOrThrow';
@@ -0,0 +1,34 @@
import { ensureAbsoluteUrl } from '@/utils/url/ensureAbsoluteUrl';
describe('ensureAbsoluteUrl', () => {
it('should return https URL as-is (trimmed)', () => {
expect(ensureAbsoluteUrl('https://example.com')).toBe(
'https://example.com',
);
});
it('should return http URL as-is', () => {
expect(ensureAbsoluteUrl('http://example.com')).toBe('http://example.com');
});
it('should return HTTPS URL as-is', () => {
expect(ensureAbsoluteUrl('HTTPS://example.com')).toBe(
'HTTPS://example.com',
);
});
it('should return HTTP URL as-is', () => {
expect(ensureAbsoluteUrl('HTTP://example.com')).toBe('HTTP://example.com');
});
it('should prepend https:// to bare domains', () => {
expect(ensureAbsoluteUrl('example.com')).toBe('https://example.com');
});
it('should trim whitespace before processing', () => {
expect(ensureAbsoluteUrl(' example.com ')).toBe('https://example.com');
expect(ensureAbsoluteUrl(' https://example.com ')).toBe(
'https://example.com',
);
});
});
@@ -1,23 +0,0 @@
import { getAbsoluteUrl } from '@/utils/url/getAbsoluteUrl';
describe('getAbsoluteUrl', () => {
it('should return https URL as-is', () => {
expect(getAbsoluteUrl('https://example.com')).toBe('https://example.com');
});
it('should return http URL as-is', () => {
expect(getAbsoluteUrl('http://example.com')).toBe('http://example.com');
});
it('should return HTTPS URL as-is', () => {
expect(getAbsoluteUrl('HTTPS://example.com')).toBe('HTTPS://example.com');
});
it('should return HTTP URL as-is', () => {
expect(getAbsoluteUrl('HTTP://example.com')).toBe('HTTP://example.com');
});
it('should prepend https:// to bare domains', () => {
expect(getAbsoluteUrl('example.com')).toBe('https://example.com');
});
});
@@ -0,0 +1,29 @@
import { normalizeUrl } from '@/utils/url/normalizeUrl';
describe('normalizeUrl', () => {
it('should return empty string for empty or whitespace input', () => {
expect(normalizeUrl('')).toBe('');
expect(normalizeUrl(' ')).toBe('');
});
it('should prepend https and normalize origin for bare domains', () => {
expect(normalizeUrl('example.com')).toBe('https://example.com');
expect(normalizeUrl(' example.com ')).toBe('https://example.com');
});
it('should lowercase the origin and preserve the path', () => {
expect(normalizeUrl('HTTPS://WWW.Example.COM/Path')).toBe(
'https://www.example.com/Path',
);
});
it('should remove trailing slash', () => {
expect(normalizeUrl('https://example.com/')).toBe('https://example.com');
});
it('should preserve percent-encoded sequences', () => {
expect(normalizeUrl('https://example.com/path%2Fencoded')).toBe(
'https://example.com/path%2Fencoded',
);
});
});
@@ -1,4 +1,4 @@
import { lowercaseUrlOriginAndRemoveTrailingSlash } from '@/utils/url/lowercaseUrlOriginAndRemoveTrailingSlash';
import { normalizeUrlOrigin } from '@/utils/url/normalizeUrlOrigin';
interface TestContext {
title: string;
@@ -6,7 +6,7 @@ interface TestContext {
expected: string;
}
describe('lowercaseUrlOriginAndRemoveTrailingSlash', () => {
describe('normalizeUrlOrigin', () => {
test.each<TestContext>([
{
title: 'should leave lowcased domain unchanged',
@@ -39,19 +39,19 @@ describe('lowercaseUrlOriginAndRemoveTrailingSlash', () => {
expected: 'https://www.example.com/TEST#Hash',
},
{
title: 'should preserve special characters in path',
input: 'https://test.test/edouard-ménard-22219837',
expected: 'https://test.test/edouard-ménard-22219837',
title: 'should percent-encode non-ASCII characters in path',
input: 'https://test.test/john-döe-22219837',
expected: 'https://test.test/john-d%C3%B6e-22219837',
},
{
title: 'should decode already encoded special characters in path',
input: 'https://test.test/edouard-m%C3%A9nard-22219837',
expected: 'https://test.test/edouard-ménard-22219837',
title: 'should preserve already encoded special characters in path',
input: 'https://test.test/john-d%C3%B6e-22219837',
expected: 'https://test.test/john-d%C3%B6e-22219837',
},
{
title: 'should preserve special characters in query params',
title: 'should percent-encode non-ASCII characters in query params',
input: 'https://example.com/path?name=José',
expected: 'https://example.com/path?name=José',
expected: 'https://example.com/path?name=Jos%C3%A9',
},
{
title:
@@ -61,12 +61,12 @@ describe('lowercaseUrlOriginAndRemoveTrailingSlash', () => {
},
{
title:
'should preserve double-encoded URLs (encoded percent signs stay encoded once)',
'should preserve double-encoded URLs without decoding percent signs',
input: 'https://example.com/test%2520name',
expected: 'https://example.com/test%20name',
expected: 'https://example.com/test%2520name',
},
{
title: 'should preserve special characters in hash fragments',
title: 'should percent-encode non-ASCII characters in hash fragments',
input: 'https://example.com/path#frédéric',
expected: 'https://example.com/path#fr%C3%A9d%C3%A9ric',
},
@@ -76,11 +76,18 @@ describe('lowercaseUrlOriginAndRemoveTrailingSlash', () => {
expected: 'https://example.com/path#fr%C3%A9d%C3%A9ric',
},
{
title: 'should handle mixed encoded and non-encoded in same URL',
title: 'should preserve encoded path and query payloads as-is',
input: 'https://example.com/path%2Fwith%2Fslashes?query=hello%20world',
expected: 'https://example.com/path/with/slashes?query=hello world',
expected: 'https://example.com/path%2Fwith%2Fslashes?query=hello%20world',
},
{
title: 'should preserve Google Maps payload encoding in path segments',
input:
'https://www.google.com/maps/place/Birdie+-+Eventlocation/data=!4m7!3m6!1s0x479e7674e1702985:0xe482992505cb1ba4!8m2!3d48.1584971!4d11.5538261!16s%2Fg%2F1ptwh8096!19sChIJhSlw4XR2nkcRpBvLBSWZguQ?authuser=0&hl=en&rclk=1',
expected:
'https://www.google.com/maps/place/Birdie+-+Eventlocation/data=!4m7!3m6!1s0x479e7674e1702985:0xe482992505cb1ba4!8m2!3d48.1584971!4d11.5538261!16s%2Fg%2F1ptwh8096!19sChIJhSlw4XR2nkcRpBvLBSWZguQ?authuser=0&hl=en&rclk=1',
},
])('$title', ({ input, expected }) => {
expect(lowercaseUrlOriginAndRemoveTrailingSlash(input)).toBe(expected);
expect(normalizeUrlOrigin(input)).toBe(expected);
});
});
@@ -1,10 +1,10 @@
import { getAbsoluteUrl } from '@/utils/url/getAbsoluteUrl';
import { ensureAbsoluteUrl } from '@/utils/url/ensureAbsoluteUrl';
import { isValidHostname } from '@/utils/url/isValidHostname';
import { z } from 'zod';
export const absoluteUrlSchema = z.string().transform((value, ctx) => {
const trimmedValue = value.trim();
const absoluteUrl = getAbsoluteUrl(trimmedValue);
const absoluteUrl = ensureAbsoluteUrl(trimmedValue);
const valueWithoutProtocol = absoluteUrl
.replace('https://', '')
@@ -0,0 +1,14 @@
export const ensureAbsoluteUrl = (value: string): string => {
const trimmedValue = value.trim();
if (
trimmedValue.startsWith('http://') ||
trimmedValue.startsWith('https://') ||
trimmedValue.startsWith('HTTPS://') ||
trimmedValue.startsWith('HTTP://')
) {
return trimmedValue;
}
return `https://${trimmedValue}`;
};
@@ -1,12 +0,0 @@
export const getAbsoluteUrl = (value: string): string => {
if (
value.startsWith('http://') ||
value.startsWith('https://') ||
value.startsWith('HTTPS://') ||
value.startsWith('HTTP://')
) {
return value;
}
return `https://${value}`;
};
@@ -1,7 +1,10 @@
export * from './absoluteUrlSchema';
export * from './ensureAbsoluteUrl';
export * from './getAbsoluteUrlOrThrow';
export * from './getUrlHostnameOrThrow';
export * from './isValidHostname';
export * from './isValidUrl';
export * from './normalizeUrl';
export * from './normalizeUrlOrigin';
export * from './buildSignedPath';
export * from './safeDecodeURIComponent';
@@ -1,19 +0,0 @@
import { getURLSafely } from '@/utils/getURLSafely';
import { isDefined } from '@/utils/validation';
import { safeDecodeURIComponent } from './safeDecodeURIComponent';
export const lowercaseUrlOriginAndRemoveTrailingSlash = (rawUrl: string) => {
const url = getURLSafely(rawUrl);
if (!isDefined(url)) {
return rawUrl;
}
const lowercaseOrigin = url.origin.toLowerCase();
const path =
safeDecodeURIComponent(url.pathname) +
safeDecodeURIComponent(url.search) +
url.hash;
return (lowercaseOrigin + path).replace(/\/$/, '');
};
@@ -0,0 +1,13 @@
import { ensureAbsoluteUrl } from '@/utils/url/ensureAbsoluteUrl';
import { normalizeUrlOrigin } from '@/utils/url/normalizeUrlOrigin';
// Ensures the URL has a protocol, lowercases the origin, and removes a trailing slash.
export const normalizeUrl = (url: string): string => {
const trimmed = url.trim();
if (trimmed === '') {
return trimmed;
}
return normalizeUrlOrigin(ensureAbsoluteUrl(trimmed));
};
@@ -0,0 +1,15 @@
import { getURLSafely } from '@/utils/getURLSafely';
import { isDefined } from '@/utils/validation';
// Lowercases the URL origin (scheme + host) and removes a trailing slash.
// URL() already lowercases the origin and preserves percent-encoded sequences
// in the path, query, and hash (e.g. %2F stays %2F, %2520 stays %2520).
export const normalizeUrlOrigin = (rawUrl: string) => {
const url = getURLSafely(rawUrl);
if (!isDefined(url)) {
return rawUrl;
}
return (url.origin + url.pathname + url.search + url.hash).replace(/\/$/, '');
};