From 42269cc45bcf8d2131dbb17d3cc6428f1e341733 Mon Sep 17 00:00:00 2001 From: Dailin <71995555+Dailin521@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:56:12 +0800 Subject: [PATCH] fix(links): preserve percent-encoded URLs during normalization (#18792) ## Summary This preserves percent-encoded payloads when normalizing links fields. `lowercaseUrlOriginAndRemoveTrailingSlash` was decoding the path and query string while lowercasing the URL origin. That changes URLs where encoded payloads are semantically significant, such as Google Maps links containing `%2F` segments. Closes #18698. ## Changes - stop decoding the path/query payload in `lowercaseUrlOriginAndRemoveTrailingSlash` - preserve the raw path, query, and hash while still lowercasing the origin and trimming a trailing slash - update shared URL normalization tests to assert encoded payloads stay encoded - add a server-side regression test covering imported links field normalization ## Validation - `corepack yarn jest --config packages/twenty-shared/jest.config.mjs packages/twenty-shared/src/utils/url/__tests__/lowercaseUrlOriginAndRemoveTrailingSlash.test.ts --runInBand` - `corepack yarn jest --config packages/twenty-server/jest.config.mjs packages/twenty-server/src/engine/core-modules/record-transformer/utils/__tests__/transform-links-value.util.spec.ts --runInBand` - `corepack yarn nx test twenty-server --runInBand --testFile=src/engine/core-modules/record-transformer/utils/__tests__/transform-links-value.util.spec.ts` --------- Co-authored-by: Charles Bochet --- .../link/utils/__tests__/normalizeUrl.test.ts | 14 ------- .../display/link/utils/normalizeUrl.ts | 11 ------ .../hooks/useAddLinkToNavigationMenuDraft.ts | 3 +- .../components/SidePanelEditLinkItemView.tsx | 8 ++-- .../useGetSecondaryRecordTableCellButton.ts | 4 +- .../buildRecordFromImportedStructuredRow.ts | 4 +- .../spreadsheetImportGetUnicityTableHook.ts | 6 +-- .../transform-links-value.util.spec.ts | 28 +++++++++++++ .../utils/transform-links-value.util.ts | 12 ++---- .../webhook/jobs/call-webhook.job.ts | 4 +- .../services/create-company.service.ts | 7 +--- packages/twenty-shared/src/utils/index.ts | 5 ++- .../url/__tests__/ensureAbsoluteUrl.test.ts | 34 ++++++++++++++++ .../url/__tests__/getAbsoluteUrl.test.ts | 23 ----------- .../utils/url/__tests__/normalizeUrl.test.ts | 29 ++++++++++++++ ...ash.test.ts => normalizeUrlOrigin.test.ts} | 39 +++++++++++-------- .../src/utils/url/absoluteUrlSchema.ts | 4 +- .../src/utils/url/ensureAbsoluteUrl.ts | 14 +++++++ .../src/utils/url/getAbsoluteUrl.ts | 12 ------ packages/twenty-shared/src/utils/url/index.ts | 3 ++ ...owercaseUrlOriginAndRemoveTrailingSlash.ts | 19 --------- .../src/utils/url/normalizeUrl.ts | 13 +++++++ .../src/utils/url/normalizeUrlOrigin.ts | 15 +++++++ 23 files changed, 182 insertions(+), 129 deletions(-) delete mode 100644 packages/twenty-front/src/modules/navigation-menu-item/display/link/utils/__tests__/normalizeUrl.test.ts delete mode 100644 packages/twenty-front/src/modules/navigation-menu-item/display/link/utils/normalizeUrl.ts create mode 100644 packages/twenty-shared/src/utils/url/__tests__/ensureAbsoluteUrl.test.ts delete mode 100644 packages/twenty-shared/src/utils/url/__tests__/getAbsoluteUrl.test.ts create mode 100644 packages/twenty-shared/src/utils/url/__tests__/normalizeUrl.test.ts rename packages/twenty-shared/src/utils/url/__tests__/{lowercaseUrlOriginAndRemoveTrailingSlash.test.ts => normalizeUrlOrigin.test.ts} (54%) create mode 100644 packages/twenty-shared/src/utils/url/ensureAbsoluteUrl.ts delete mode 100644 packages/twenty-shared/src/utils/url/getAbsoluteUrl.ts delete mode 100644 packages/twenty-shared/src/utils/url/lowercaseUrlOriginAndRemoveTrailingSlash.ts create mode 100644 packages/twenty-shared/src/utils/url/normalizeUrl.ts create mode 100644 packages/twenty-shared/src/utils/url/normalizeUrlOrigin.ts diff --git a/packages/twenty-front/src/modules/navigation-menu-item/display/link/utils/__tests__/normalizeUrl.test.ts b/packages/twenty-front/src/modules/navigation-menu-item/display/link/utils/__tests__/normalizeUrl.test.ts deleted file mode 100644 index de563055ca..0000000000 --- a/packages/twenty-front/src/modules/navigation-menu-item/display/link/utils/__tests__/normalizeUrl.test.ts +++ /dev/null @@ -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(''); - }); -}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/display/link/utils/normalizeUrl.ts b/packages/twenty-front/src/modules/navigation-menu-item/display/link/utils/normalizeUrl.ts deleted file mode 100644 index 8f1fb1864b..0000000000 --- a/packages/twenty-front/src/modules/navigation-menu-item/display/link/utils/normalizeUrl.ts +++ /dev/null @@ -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}`; -}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/edit/link/hooks/useAddLinkToNavigationMenuDraft.ts b/packages/twenty-front/src/modules/navigation-menu-item/edit/link/hooks/useAddLinkToNavigationMenuDraft.ts index e835fb9af1..82d2f43247 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/edit/link/hooks/useAddLinkToNavigationMenuDraft.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/edit/link/hooks/useAddLinkToNavigationMenuDraft.ts @@ -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 = () => { diff --git a/packages/twenty-front/src/modules/navigation-menu-item/edit/side-panel/components/SidePanelEditLinkItemView.tsx b/packages/twenty-front/src/modules/navigation-menu-item/edit/side-panel/components/SidePanelEditLinkItemView.tsx index 99eb96c36b..ecace03c50 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/edit/side-panel/components/SidePanelEditLinkItemView.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/edit/side-panel/components/SidePanelEditLinkItemView.tsx @@ -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) => { const value = event.target.value.trim(); if (isNonEmptyString(value)) { - onUpdateLink(selectedItem.id, { link: getAbsoluteUrl(value) }); + onUpdateLink(selectedItem.id, { link: ensureAbsoluteUrl(value) }); setUrlEditInput(''); } }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useGetSecondaryRecordTableCellButton.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useGetSecondaryRecordTableCellButton.ts index c5f7d2e36d..aa9762a370 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useGetSecondaryRecordTableCellButton.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useGetSecondaryRecordTableCellButton.ts @@ -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`); diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts index ca1d30ff87..a881d70e2e 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts @@ -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, }, diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/spreadsheetImportGetUnicityTableHook.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/spreadsheetImportGetUnicityTableHook.ts index 21aa28aca9..510d2c14c9 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/spreadsheetImportGetUnicityTableHook.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/spreadsheetImportGetUnicityTableHook.ts @@ -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(); diff --git a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/__tests__/transform-links-value.util.spec.ts b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/__tests__/transform-links-value.util.spec.ts index f51a9df1a5..e3e1aa45ab 100644 --- a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/__tests__/transform-links-value.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/__tests__/transform-links-value.util.spec.ts @@ -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); + }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-links-value.util.ts b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-links-value.util.ts index 5fc8861cec..03a7c8c3d0 100644 --- a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-links-value.util.ts +++ b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/transform-links-value.util.ts @@ -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) diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook.job.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook.job.ts index 2f07969bde..82da0a4d39 100644 --- a/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook.job.ts +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook.job.ts @@ -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, diff --git a/packages/twenty-server/src/modules/contact-creation-manager/services/create-company.service.ts b/packages/twenty-server/src/modules/contact-creation-manager/services/create-company.service.ts index f655b19efe..f611077007 100644 --- a/packages/twenty-server/src/modules/contact-creation-manager/services/create-company.service.ts +++ b/packages/twenty-server/src/modules/contact-creation-manager/services/create-company.service.ts @@ -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, })); diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 190a3305d5..34c2cf84d5 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -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'; diff --git a/packages/twenty-shared/src/utils/url/__tests__/ensureAbsoluteUrl.test.ts b/packages/twenty-shared/src/utils/url/__tests__/ensureAbsoluteUrl.test.ts new file mode 100644 index 0000000000..af86044a20 --- /dev/null +++ b/packages/twenty-shared/src/utils/url/__tests__/ensureAbsoluteUrl.test.ts @@ -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', + ); + }); +}); diff --git a/packages/twenty-shared/src/utils/url/__tests__/getAbsoluteUrl.test.ts b/packages/twenty-shared/src/utils/url/__tests__/getAbsoluteUrl.test.ts deleted file mode 100644 index c22d5a859a..0000000000 --- a/packages/twenty-shared/src/utils/url/__tests__/getAbsoluteUrl.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/packages/twenty-shared/src/utils/url/__tests__/normalizeUrl.test.ts b/packages/twenty-shared/src/utils/url/__tests__/normalizeUrl.test.ts new file mode 100644 index 0000000000..ee30b5f427 --- /dev/null +++ b/packages/twenty-shared/src/utils/url/__tests__/normalizeUrl.test.ts @@ -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', + ); + }); +}); diff --git a/packages/twenty-shared/src/utils/url/__tests__/lowercaseUrlOriginAndRemoveTrailingSlash.test.ts b/packages/twenty-shared/src/utils/url/__tests__/normalizeUrlOrigin.test.ts similarity index 54% rename from packages/twenty-shared/src/utils/url/__tests__/lowercaseUrlOriginAndRemoveTrailingSlash.test.ts rename to packages/twenty-shared/src/utils/url/__tests__/normalizeUrlOrigin.test.ts index 7156ee94c8..a6b83faf93 100644 --- a/packages/twenty-shared/src/utils/url/__tests__/lowercaseUrlOriginAndRemoveTrailingSlash.test.ts +++ b/packages/twenty-shared/src/utils/url/__tests__/normalizeUrlOrigin.test.ts @@ -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([ { 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); }); }); diff --git a/packages/twenty-shared/src/utils/url/absoluteUrlSchema.ts b/packages/twenty-shared/src/utils/url/absoluteUrlSchema.ts index 2e55781855..ab7562c077 100644 --- a/packages/twenty-shared/src/utils/url/absoluteUrlSchema.ts +++ b/packages/twenty-shared/src/utils/url/absoluteUrlSchema.ts @@ -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://', '') diff --git a/packages/twenty-shared/src/utils/url/ensureAbsoluteUrl.ts b/packages/twenty-shared/src/utils/url/ensureAbsoluteUrl.ts new file mode 100644 index 0000000000..4f1d2d7db0 --- /dev/null +++ b/packages/twenty-shared/src/utils/url/ensureAbsoluteUrl.ts @@ -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}`; +}; diff --git a/packages/twenty-shared/src/utils/url/getAbsoluteUrl.ts b/packages/twenty-shared/src/utils/url/getAbsoluteUrl.ts deleted file mode 100644 index aee0c2e293..0000000000 --- a/packages/twenty-shared/src/utils/url/getAbsoluteUrl.ts +++ /dev/null @@ -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}`; -}; diff --git a/packages/twenty-shared/src/utils/url/index.ts b/packages/twenty-shared/src/utils/url/index.ts index 5400bf560e..1a8ca83304 100644 --- a/packages/twenty-shared/src/utils/url/index.ts +++ b/packages/twenty-shared/src/utils/url/index.ts @@ -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'; diff --git a/packages/twenty-shared/src/utils/url/lowercaseUrlOriginAndRemoveTrailingSlash.ts b/packages/twenty-shared/src/utils/url/lowercaseUrlOriginAndRemoveTrailingSlash.ts deleted file mode 100644 index 26a5bf4829..0000000000 --- a/packages/twenty-shared/src/utils/url/lowercaseUrlOriginAndRemoveTrailingSlash.ts +++ /dev/null @@ -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(/\/$/, ''); -}; diff --git a/packages/twenty-shared/src/utils/url/normalizeUrl.ts b/packages/twenty-shared/src/utils/url/normalizeUrl.ts new file mode 100644 index 0000000000..76e7c718c3 --- /dev/null +++ b/packages/twenty-shared/src/utils/url/normalizeUrl.ts @@ -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)); +}; diff --git a/packages/twenty-shared/src/utils/url/normalizeUrlOrigin.ts b/packages/twenty-shared/src/utils/url/normalizeUrlOrigin.ts new file mode 100644 index 0000000000..fb6b1b056e --- /dev/null +++ b/packages/twenty-shared/src/utils/url/normalizeUrlOrigin.ts @@ -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(/\/$/, ''); +};