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:
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+23
-16
@@ -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(/\/$/, '');
|
||||
};
|
||||
Reference in New Issue
Block a user