From 9046a9ac16d2d8840d69bcea38889de601c2f65b Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Mon, 24 Feb 2025 18:01:51 +0100 Subject: [PATCH] Migrate url tooling to twenty-shared (#10440) Migrate and unify URL tooling in twenty-shared. We now have: - isValidHostname which follows our own business rules - a zod schema that can be re-used in different context and leverages is isValidHostname - isValidUrl on top of the zod schema - a getAbsoluteURl and getHostname on top of the zod schema I have added a LOT of tests to cover all the cases I've found Also fixes: https://github.com/twentyhq/twenty/issues/10147 --- .../input/components/LinksFieldInput.tsx | 3 +- .../types/guards/isFieldLinksValue.ts | 3 +- ...etSpreadSheetFieldValidationDefinitions.ts | 3 +- .../SettingsAccountsBlocklistInput.tsx | 17 +++-- .../SettingsDevelopersWebhookTableRow.tsx | 4 +- .../developers/hooks/useWebhookUpdateForm.ts | 26 +++---- .../field/display/components/LinksDisplay.tsx | 20 ++++-- .../src/utils/__tests__/is-domain.test.ts | 35 --------- packages/twenty-front/src/utils/is-domain.ts | 7 -- .../url/__tests__/getUrlHostName.test.ts | 25 ------- .../src/utils/url/getAbsoluteUrl.ts | 9 --- .../src/utils/url/getUrlHostname.ts | 13 ---- .../twenty-front/src/utils/url/isValidUrl.ts | 8 --- .../validation-schemas/absoluteUrlSchema.ts | 23 ------ packages/twenty-shared/jest.config.ts | 1 + packages/twenty-shared/src/index.ts | 1 - packages/twenty-shared/src/utils/index.ts | 1 + .../url}/__tests__/absoluteUrlSchema.test.ts | 3 +- .../__tests__/getAbsoluteUrlOrThrow.test.ts | 16 +++++ .../__tests__/getUrlHostnameOrThrow.test.ts | 30 ++++++++ .../url/__tests__/isValidHostname.test.ts | 71 +++++++++++++++++++ .../utils/url/__tests__/isValidUrl.test.ts | 4 ++ .../src/utils/url/absoluteUrlSchema.ts | 52 ++++++++++++++ .../src/utils/url/getAbsoluteUrlOrThrow.ts | 9 +++ .../src/utils/url/getUrlHostnameOrThrow.ts | 16 +++++ packages/twenty-shared/src/utils/url/index.ts | 5 ++ .../src/utils/url/isValidHostname.ts | 26 +++++++ .../twenty-shared/src/utils/url/isValidUrl.ts | 7 ++ 28 files changed, 280 insertions(+), 158 deletions(-) delete mode 100644 packages/twenty-front/src/utils/__tests__/is-domain.test.ts delete mode 100644 packages/twenty-front/src/utils/is-domain.ts delete mode 100644 packages/twenty-front/src/utils/url/__tests__/getUrlHostName.test.ts delete mode 100644 packages/twenty-front/src/utils/url/getAbsoluteUrl.ts delete mode 100644 packages/twenty-front/src/utils/url/getUrlHostname.ts delete mode 100644 packages/twenty-front/src/utils/url/isValidUrl.ts delete mode 100644 packages/twenty-front/src/utils/validation-schemas/absoluteUrlSchema.ts rename packages/{twenty-front/src/utils/validation-schemas => twenty-shared/src/utils/url}/__tests__/absoluteUrlSchema.test.ts (89%) create mode 100644 packages/twenty-shared/src/utils/url/__tests__/getAbsoluteUrlOrThrow.test.ts create mode 100644 packages/twenty-shared/src/utils/url/__tests__/getUrlHostnameOrThrow.test.ts create mode 100644 packages/twenty-shared/src/utils/url/__tests__/isValidHostname.test.ts rename packages/{twenty-front => twenty-shared}/src/utils/url/__tests__/isValidUrl.test.ts (84%) create mode 100644 packages/twenty-shared/src/utils/url/absoluteUrlSchema.ts create mode 100644 packages/twenty-shared/src/utils/url/getAbsoluteUrlOrThrow.ts create mode 100644 packages/twenty-shared/src/utils/url/getUrlHostnameOrThrow.ts create mode 100644 packages/twenty-shared/src/utils/url/index.ts create mode 100644 packages/twenty-shared/src/utils/url/isValidHostname.ts create mode 100644 packages/twenty-shared/src/utils/url/isValidUrl.ts diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx index 7415a761c..b1cff0767 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx @@ -1,9 +1,8 @@ import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField'; import { LinksFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/LinksFieldMenuItem'; import { useMemo } from 'react'; -import { isDefined } from 'twenty-shared'; +import { absoluteUrlSchema, isDefined } from 'twenty-shared'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema'; import { MultiItemFieldInput } from './MultiItemFieldInput'; type LinksFieldInputProps = { diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinksValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinksValue.ts index 6f519b676..da88be839 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinksValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinksValue.ts @@ -1,7 +1,6 @@ import { z } from 'zod'; -import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema'; - +import { absoluteUrlSchema } from 'twenty-shared'; import { FieldLinksValue } from '../FieldMetadata'; export const linksSchema = z.object({ diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts index 89f20e960..1621beaf3 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts @@ -1,7 +1,6 @@ import { FieldValidationDefinition } from '@/spreadsheet-import/types'; -import { isDefined, isValidUuid } from 'twenty-shared'; +import { absoluteUrlSchema, isDefined, isValidUuid } from 'twenty-shared'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema'; export const getSpreadSheetFieldValidationDefinitions = ( type: FieldMetadataType, diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsBlocklistInput.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsBlocklistInput.tsx index a700f7ad8..86121be26 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsBlocklistInput.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsBlocklistInput.tsx @@ -7,8 +7,8 @@ import { z } from 'zod'; import { TextInput } from '@/ui/input/components/TextInput'; import { useLingui } from '@lingui/react/macro'; +import { isValidHostname } from 'twenty-shared'; import { Button } from 'twenty-ui'; -import { isDomain } from '~/utils/is-domain'; const StyledContainer = styled.div` display: flex; @@ -43,12 +43,15 @@ export const SettingsAccountsBlocklistInput = ({ .trim() .email(t`Invalid email or domain`) .or( - z - .string() - .refine( - (value) => value.startsWith('@') && isDomain(value.slice(1)), - t`Invalid email or domain`, - ), + z.string().refine( + (value) => + value.startsWith('@') && + isValidHostname(value.slice(1), { + allowIp: false, + allowLocalhost: false, + }), + t`Invalid email or domain`, + ), ) .refine( (value) => !blockedEmailOrDomainList.includes(value), diff --git a/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookTableRow.tsx b/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookTableRow.tsx index c848cd0d0..62f16260c 100644 --- a/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookTableRow.tsx +++ b/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookTableRow.tsx @@ -5,7 +5,7 @@ import { IconChevronRight } from 'twenty-ui'; import { Webhook } from '@/settings/developers/types/webhook/Webhook'; import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableRow } from '@/ui/layout/table/components/TableRow'; -import { getUrlHostname } from '~/utils/url/getUrlHostname'; +import { getUrlHostnameOrThrow } from 'twenty-shared'; export const StyledApisFieldTableRow = styled(TableRow)` grid-template-columns: 1fr 28px; @@ -39,7 +39,7 @@ export const SettingsDevelopersWebhookTableRow = ({ return ( - {getUrlHostname(fieldItem.targetUrl, { keepPath: true })} + {getUrlHostnameOrThrow(fieldItem.targetUrl)} { ] .filter(isDefined) .map(({ url, label }) => { - const absoluteUrl = getAbsoluteUrl(url); + let absoluteUrl = ''; + let hostname = ''; + try { + absoluteUrl = getAbsoluteUrlOrThrow(url); + hostname = getUrlHostnameOrThrow(absoluteUrl); + } catch { + absoluteUrl = ''; + hostname = ''; + } return { url: absoluteUrl, - label: label || getUrlHostname(absoluteUrl), + label: label || hostname, type: checkUrlType(absoluteUrl), }; }), diff --git a/packages/twenty-front/src/utils/__tests__/is-domain.test.ts b/packages/twenty-front/src/utils/__tests__/is-domain.test.ts deleted file mode 100644 index d77a9ddca..000000000 --- a/packages/twenty-front/src/utils/__tests__/is-domain.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { isDomain } from '~/utils/is-domain'; - -describe('isDomain', () => { - it(`should return false if null`, () => { - expect(isDomain(null)).toBeFalsy(); - }); - - it(`should return false if undefined`, () => { - expect(isDomain(undefined)).toBeFalsy(); - }); - - it(`should return true if string google`, () => { - expect(isDomain('google')).toBeFalsy(); - }); - - it(`should return true if string google.com`, () => { - expect(isDomain('google.com')).toBeTruthy(); - }); - - it(`should return true if string bbc.co.uk`, () => { - expect(isDomain('bbc.co.uk')).toBeTruthy(); - }); - - it(`should return true if string web.io`, () => { - expect(isDomain('web.io')).toBeTruthy(); - }); - - it(`should return true if string x.com`, () => { - expect(isDomain('x.com')).toBeTruthy(); - }); - - it(`should return true if string 2.com`, () => { - expect(isDomain('2.com')).toBeTruthy(); - }); -}); diff --git a/packages/twenty-front/src/utils/is-domain.ts b/packages/twenty-front/src/utils/is-domain.ts deleted file mode 100644 index 94f0f3539..000000000 --- a/packages/twenty-front/src/utils/is-domain.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { isDefined } from 'twenty-shared'; - -export const isDomain = (url: string | undefined | null) => - isDefined(url) && - /^((?!-))(xn--)?[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]{0,1}\.(xn--)?([a-z0-9-]{1,61}|[a-z0-9-]{1,30}\.[a-z]{2,})$/.test( - url, - ); diff --git a/packages/twenty-front/src/utils/url/__tests__/getUrlHostName.test.ts b/packages/twenty-front/src/utils/url/__tests__/getUrlHostName.test.ts deleted file mode 100644 index 5d67ecf92..000000000 --- a/packages/twenty-front/src/utils/url/__tests__/getUrlHostName.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { getUrlHostname } from '~/utils/url/getUrlHostname'; - -describe('getUrlHostname', () => { - it("returns the URL's hostname", () => { - expect(getUrlHostname('https://www.example.com')).toBe('example.com'); - expect(getUrlHostname('http://subdomain.example.com')).toBe( - 'subdomain.example.com', - ); - expect(getUrlHostname('https://www.example.com/path')).toBe('example.com'); - expect(getUrlHostname('https://www.example.com?query=123')).toBe( - 'example.com', - ); - expect(getUrlHostname('http://localhost:3000')).toBe('localhost'); - expect(getUrlHostname('example.com')).toBe('example.com'); - expect(getUrlHostname('www.subdomain.example.com')).toBe( - 'subdomain.example.com', - ); - }); - - it('returns an empty string for invalid URLs', () => { - expect(getUrlHostname('?o')).toBe(''); - expect(getUrlHostname('')).toBe(''); - expect(getUrlHostname('\\')).toBe(''); - }); -}); diff --git a/packages/twenty-front/src/utils/url/getAbsoluteUrl.ts b/packages/twenty-front/src/utils/url/getAbsoluteUrl.ts deleted file mode 100644 index 39be8641c..000000000 --- a/packages/twenty-front/src/utils/url/getAbsoluteUrl.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema'; - -export const getAbsoluteUrl = (url: string) => { - try { - return absoluteUrlSchema.parse(url); - } catch { - return ''; - } -}; diff --git a/packages/twenty-front/src/utils/url/getUrlHostname.ts b/packages/twenty-front/src/utils/url/getUrlHostname.ts deleted file mode 100644 index 601fc48f6..000000000 --- a/packages/twenty-front/src/utils/url/getUrlHostname.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { getAbsoluteUrl } from '~/utils/url/getAbsoluteUrl'; - -export const getUrlHostname = ( - url: string, - options?: { keepPath?: boolean }, -) => { - try { - const parsedUrl = new URL(getAbsoluteUrl(url)); - return `${parsedUrl.hostname.replace(/^www\./i, '')}${options?.keepPath && parsedUrl.pathname !== '/' ? parsedUrl.pathname : ''}`; - } catch { - return ''; - } -}; diff --git a/packages/twenty-front/src/utils/url/isValidUrl.ts b/packages/twenty-front/src/utils/url/isValidUrl.ts deleted file mode 100644 index 195e445aa..000000000 --- a/packages/twenty-front/src/utils/url/isValidUrl.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const isValidUrl = (url: string) => { - const urlRegex = - /^(https?:\/\/)?((([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|(localhost))(:\d+)?(\/[^\s]*)?(\?[^\s]*)?$/; - - const urlPattern = new RegExp(urlRegex, 'i'); - - return !!urlPattern.test(url); -}; diff --git a/packages/twenty-front/src/utils/validation-schemas/absoluteUrlSchema.ts b/packages/twenty-front/src/utils/validation-schemas/absoluteUrlSchema.ts deleted file mode 100644 index 0627b98e9..000000000 --- a/packages/twenty-front/src/utils/validation-schemas/absoluteUrlSchema.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from 'zod'; - -export const absoluteUrlSchema = z - .string() - .url() - .or( - z - .string() - .transform((value) => { - try { - const url = `https://${value}`.trim(); - return isNaN(Number(value.trim())) && - new URL(url) && - /\.[a-z]{2,}$/.test(url) - ? url - : ''; - } catch { - return ''; - } - }) - .pipe(z.string().url()), - ) - .or(z.literal('')); diff --git a/packages/twenty-shared/jest.config.ts b/packages/twenty-shared/jest.config.ts index e88407e78..f00793a8f 100644 --- a/packages/twenty-shared/jest.config.ts +++ b/packages/twenty-shared/jest.config.ts @@ -4,6 +4,7 @@ import { JestConfigWithTsJest, pathsToModuleNameMapper } from 'ts-jest'; const tsConfig = require('./tsconfig.json'); const jestConfig: JestConfigWithTsJest = { + silent: true, displayName: 'twenty-ui', preset: '../../jest.preset.js', testEnvironment: 'jsdom', diff --git a/packages/twenty-shared/src/index.ts b/packages/twenty-shared/src/index.ts index 8d3f43064..6404c1b7d 100644 --- a/packages/twenty-shared/src/index.ts +++ b/packages/twenty-shared/src/index.ts @@ -3,4 +3,3 @@ export * from './i18n'; export * from './types'; export * from './utils'; export * from './workspace'; - diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 1b7d29622..78cc26d34 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './fieldMetadata'; export * from './image'; export * from './strings'; +export * from './url'; export * from './validation'; diff --git a/packages/twenty-front/src/utils/validation-schemas/__tests__/absoluteUrlSchema.test.ts b/packages/twenty-shared/src/utils/url/__tests__/absoluteUrlSchema.test.ts similarity index 89% rename from packages/twenty-front/src/utils/validation-schemas/__tests__/absoluteUrlSchema.test.ts rename to packages/twenty-shared/src/utils/url/__tests__/absoluteUrlSchema.test.ts index ffb83cfc8..c9f4d21c6 100644 --- a/packages/twenty-front/src/utils/validation-schemas/__tests__/absoluteUrlSchema.test.ts +++ b/packages/twenty-shared/src/utils/url/__tests__/absoluteUrlSchema.test.ts @@ -1,4 +1,4 @@ -import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema'; +import { absoluteUrlSchema } from '../absoluteUrlSchema'; describe('absoluteUrlSchema', () => { it('validates an absolute url', () => { @@ -27,6 +27,7 @@ describe('absoluteUrlSchema', () => { }); it('fails for invalid urls', () => { + expect(absoluteUrlSchema.safeParse('https://2').success).toBe(false); expect(absoluteUrlSchema.safeParse('?o').success).toBe(false); expect(absoluteUrlSchema.safeParse('\\').success).toBe(false); }); diff --git a/packages/twenty-shared/src/utils/url/__tests__/getAbsoluteUrlOrThrow.test.ts b/packages/twenty-shared/src/utils/url/__tests__/getAbsoluteUrlOrThrow.test.ts new file mode 100644 index 000000000..cbf37d2e0 --- /dev/null +++ b/packages/twenty-shared/src/utils/url/__tests__/getAbsoluteUrlOrThrow.test.ts @@ -0,0 +1,16 @@ +import { getAbsoluteUrlOrThrow } from 'src/utils/url/getAbsoluteUrlOrThrow'; + +describe('getAbsoluteUrlOrThrow', () => { + it("returns the URL's hostname", () => { + expect(getAbsoluteUrlOrThrow('https://www.example.com')).toBe( + 'https://www.example.com', + ); + }); + + it('returns an empty string for invalid URLs', () => { + expect(() => getAbsoluteUrlOrThrow('?o')).toThrow('Invalid URL'); + expect(() => getAbsoluteUrlOrThrow('')).toThrow('Invalid URL'); + expect(() => getAbsoluteUrlOrThrow('\\')).toThrow('Invalid URL'); + expect(() => getAbsoluteUrlOrThrow('2')).toThrow('Invalid URL'); + }); +}); diff --git a/packages/twenty-shared/src/utils/url/__tests__/getUrlHostnameOrThrow.test.ts b/packages/twenty-shared/src/utils/url/__tests__/getUrlHostnameOrThrow.test.ts new file mode 100644 index 000000000..2b5272c0b --- /dev/null +++ b/packages/twenty-shared/src/utils/url/__tests__/getUrlHostnameOrThrow.test.ts @@ -0,0 +1,30 @@ +import { getUrlHostnameOrThrow } from 'src/utils/url/getUrlHostnameOrThrow'; + +describe('getUrlHostnameOrThrow', () => { + it("returns the URL's hostname", () => { + expect(getUrlHostnameOrThrow('https://www.example.com')).toBe( + 'www.example.com', + ); + expect(getUrlHostnameOrThrow('http://subdomain.example.com')).toBe( + 'subdomain.example.com', + ); + expect(getUrlHostnameOrThrow('https://www.example.com/path')).toBe( + 'www.example.com', + ); + expect(getUrlHostnameOrThrow('https://www.example.com?query=123')).toBe( + 'www.example.com', + ); + expect(getUrlHostnameOrThrow('http://localhost:3000')).toBe('localhost'); + expect(getUrlHostnameOrThrow('example.com')).toBe('example.com'); + expect(getUrlHostnameOrThrow('www.subdomain.example.com')).toBe( + 'www.subdomain.example.com', + ); + }); + + it('returns an empty string for invalid URLs', () => { + expect(() => getUrlHostnameOrThrow('?o')).toThrow('Invalid URL'); + expect(() => getUrlHostnameOrThrow('')).toThrow('Invalid URL'); + expect(() => getUrlHostnameOrThrow('\\')).toThrow('Invalid URL'); + expect(() => getUrlHostnameOrThrow('2')).toThrow('Invalid URL'); + }); +}); diff --git a/packages/twenty-shared/src/utils/url/__tests__/isValidHostname.test.ts b/packages/twenty-shared/src/utils/url/__tests__/isValidHostname.test.ts new file mode 100644 index 000000000..d39aac6d3 --- /dev/null +++ b/packages/twenty-shared/src/utils/url/__tests__/isValidHostname.test.ts @@ -0,0 +1,71 @@ +import { isValidHostname } from 'src/utils/url/isValidHostname'; + +describe('isValidHostname', () => { + it(`should return true if string google`, () => { + expect(isValidHostname('google')).toBeFalsy(); + }); + + it(`should return true if string google.com`, () => { + expect(isValidHostname('google.com')).toBeTruthy(); + }); + + it(`should return true if string bbc.co.uk`, () => { + expect(isValidHostname('bbc.co.uk')).toBeTruthy(); + }); + + it(`should return true if string www.subdomain.example.com`, () => { + expect(isValidHostname('www.subdomain.example.com')).toBeTruthy(); + }); + + it(`should return true if string web.io`, () => { + expect(isValidHostname('web.io')).toBeTruthy(); + }); + + it(`should return true if string x.com`, () => { + expect(isValidHostname('x.com')).toBeTruthy(); + }); + + it(`should return true if string 2.com`, () => { + expect(isValidHostname('2.com')).toBeTruthy(); + }); + + it(`should return true if string localhost`, () => { + expect(isValidHostname('localhost')).toBeTruthy(); + }); + + it(`should return true if string 127.0.0.1`, () => { + expect(isValidHostname('127.0.0.1')).toBeTruthy(); + }); + + it(`should return false if string 2`, () => { + expect(isValidHostname('2')).toBeFalsy(); + }); + + it(`should return false if string contains non-valid characters`, () => { + expect(isValidHostname('subdomain.example.com/path')).toBeFalsy(); + }); + + it(`should return false if string is empty`, () => { + expect(isValidHostname('')).toBeFalsy(); + }); + + it(`should return false if string is one word`, () => { + expect(isValidHostname('subdomain')).toBeFalsy(); + }); + + it(`should return true if string is ip address`, () => { + expect(isValidHostname('192.168.2.0')).toBeTruthy(); + }); + + it(`should return false if string is ip address but allowIp is false`, () => { + expect(isValidHostname('192.168.2.0', { allowIp: false })).toBeFalsy(); + }); + + it(`should return true if string is localhost but allowLocalhost is false`, () => { + expect(isValidHostname('localhost', { allowLocalhost: false })).toBeFalsy(); + }); + + it(`should return true if string is localhost but allowLocalhost is true`, () => { + expect(isValidHostname('localhost', { allowLocalhost: true })).toBeTruthy(); + }); +}); diff --git a/packages/twenty-front/src/utils/url/__tests__/isValidUrl.test.ts b/packages/twenty-shared/src/utils/url/__tests__/isValidUrl.test.ts similarity index 84% rename from packages/twenty-front/src/utils/url/__tests__/isValidUrl.test.ts rename to packages/twenty-shared/src/utils/url/__tests__/isValidUrl.test.ts index 6279a9bba..e9a5024f4 100644 --- a/packages/twenty-front/src/utils/url/__tests__/isValidUrl.test.ts +++ b/packages/twenty-shared/src/utils/url/__tests__/isValidUrl.test.ts @@ -15,11 +15,15 @@ describe('isValidUrl', () => { expect(isValidUrl('http://localhost:3000')).toBe(true); expect(isValidUrl('example.com')).toBe(true); expect(isValidUrl('www.subdomain.example.com')).toBe(true); + expect(isValidUrl('192.168.2.0')).toBe(true); + expect(isValidUrl('3.com')).toBe(true); // Falsy expect(isValidUrl('?o')).toBe(false); expect(isValidUrl('')).toBe(false); expect(isValidUrl('\\')).toBe(false); expect(isValidUrl('wwwexamplecom')).toBe(false); + expect(isValidUrl('2/toto')).toBe(false); + expect(isValidUrl('2')).toBe(false); }); }); diff --git a/packages/twenty-shared/src/utils/url/absoluteUrlSchema.ts b/packages/twenty-shared/src/utils/url/absoluteUrlSchema.ts new file mode 100644 index 000000000..db2570fc0 --- /dev/null +++ b/packages/twenty-shared/src/utils/url/absoluteUrlSchema.ts @@ -0,0 +1,52 @@ +import { isValidHostname } from 'src/utils/url/isValidHostname'; +import { z } from 'zod'; + +const getAbsoluteUrl = (value: string): string => { + if (value.startsWith('http://') || value.startsWith('https://')) { + return value; + } + + return `https://${value}`; +}; + +export const absoluteUrlSchema = z.string().transform((value, ctx) => { + const trimmedValue = value.trim(); + const absoluteUrl = getAbsoluteUrl(trimmedValue); + + const valueWithoutProtocol = absoluteUrl + .replace('https://', '') + .replace('http://', ''); + + if (/^\d+(?:\/[a-zA-Z]*)?$/.test(valueWithoutProtocol)) { + // if the hostname is a number, it's not a valid url + // if we let URL() parse it, it will throw cast an IP address and we lose the information + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'domain is not a valid url', + }); + + return z.NEVER; + } + + try { + const url = new URL(absoluteUrl); + + if (isValidHostname(url.hostname)) { + return absoluteUrl; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'domain is not a valid url', + }); + + return z.NEVER; + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'domain is not a valid url', + }); + + return z.NEVER; + } +}); diff --git a/packages/twenty-shared/src/utils/url/getAbsoluteUrlOrThrow.ts b/packages/twenty-shared/src/utils/url/getAbsoluteUrlOrThrow.ts new file mode 100644 index 000000000..a9427a27c --- /dev/null +++ b/packages/twenty-shared/src/utils/url/getAbsoluteUrlOrThrow.ts @@ -0,0 +1,9 @@ +import { absoluteUrlSchema } from 'src/utils/url/absoluteUrlSchema'; + +export const getAbsoluteUrlOrThrow = (url: string): string => { + try { + return absoluteUrlSchema.parse(url); + } catch { + throw new Error('Invalid URL'); + } +}; diff --git a/packages/twenty-shared/src/utils/url/getUrlHostnameOrThrow.ts b/packages/twenty-shared/src/utils/url/getUrlHostnameOrThrow.ts new file mode 100644 index 000000000..ca5bdb26c --- /dev/null +++ b/packages/twenty-shared/src/utils/url/getUrlHostnameOrThrow.ts @@ -0,0 +1,16 @@ +import { absoluteUrlSchema } from 'src/utils/url/absoluteUrlSchema'; + +export const getUrlHostnameOrThrow = (url: string): string => { + const result = absoluteUrlSchema.safeParse(url); + + if (!result.success) { + throw new Error('Invalid URL'); + } + + try { + const url = new URL(result.data); + return url.hostname; + } catch { + throw new Error('Invalid URL'); + } +}; diff --git a/packages/twenty-shared/src/utils/url/index.ts b/packages/twenty-shared/src/utils/url/index.ts new file mode 100644 index 000000000..9e4c6ac74 --- /dev/null +++ b/packages/twenty-shared/src/utils/url/index.ts @@ -0,0 +1,5 @@ +export * from './absoluteUrlSchema'; +export * from './getAbsoluteUrlOrThrow'; +export * from './getUrlHostnameOrThrow'; +export * from './isValidHostname'; +export * from './isValidUrl'; diff --git a/packages/twenty-shared/src/utils/url/isValidHostname.ts b/packages/twenty-shared/src/utils/url/isValidHostname.ts new file mode 100644 index 000000000..d2144613b --- /dev/null +++ b/packages/twenty-shared/src/utils/url/isValidHostname.ts @@ -0,0 +1,26 @@ +export const isValidHostname = ( + url: string, + options?: { + allowLocalhost?: boolean; + allowIp?: boolean; + }, +): boolean => { + const allowIp = options?.allowIp ?? true; + const allowLocalhost = options?.allowLocalhost ?? true; + + const regex = + /^(((?!-))(xn--|_)?[a-z0-9-]{0,61}[a-z0-9]{1,1}\.){1,10}(xn--)?([a-z0-9][a-z0-9-]{0,60}|[a-z0-9-]{1,30}\.[a-z]{2,})$/; + const isMatch = regex.test(url); + const isIp = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(url); + const isLocalhost = url === 'localhost' || url === '127.0.0.1'; + + if (isLocalhost && !allowLocalhost) { + return false; + } + + if (isIp && !allowIp) { + return false; + } + + return isMatch || isLocalhost || isIp; +}; diff --git a/packages/twenty-shared/src/utils/url/isValidUrl.ts b/packages/twenty-shared/src/utils/url/isValidUrl.ts new file mode 100644 index 000000000..dec55b695 --- /dev/null +++ b/packages/twenty-shared/src/utils/url/isValidUrl.ts @@ -0,0 +1,7 @@ +import { absoluteUrlSchema } from 'src/utils/url/absoluteUrlSchema'; + +export const isValidUrl = (url: string): boolean => { + const result = absoluteUrlSchema.safeParse(url); + + return result.success; +};