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 <charles@twenty.com>
This commit is contained in:
Dailin
2026-03-21 00:56:12 +08:00
committed by GitHub
parent 1be0f1554f
commit 42269cc45b
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(/\/$/, '');
};