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
This commit is contained in:
Charles Bochet
2025-02-24 18:01:51 +01:00
committed by GitHub
parent d4bdae562f
commit 9046a9ac16
28 changed files with 280 additions and 158 deletions

View File

@ -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',

View File

@ -3,4 +3,3 @@ export * from './i18n';
export * from './types';
export * from './utils';
export * from './workspace';

View File

@ -1,4 +1,5 @@
export * from './fieldMetadata';
export * from './image';
export * from './strings';
export * from './url';
export * from './validation';

View File

@ -0,0 +1,34 @@
import { absoluteUrlSchema } from '../absoluteUrlSchema';
describe('absoluteUrlSchema', () => {
it('validates an absolute url', () => {
expect(absoluteUrlSchema.parse('https://www.example.com')).toBe(
'https://www.example.com',
);
expect(absoluteUrlSchema.parse('http://subdomain.example.com')).toBe(
'http://subdomain.example.com',
);
expect(absoluteUrlSchema.parse('https://www.example.com/path')).toBe(
'https://www.example.com/path',
);
expect(absoluteUrlSchema.parse('https://www.example.com?query=123')).toBe(
'https://www.example.com?query=123',
);
expect(absoluteUrlSchema.parse('http://localhost:3000')).toBe(
'http://localhost:3000',
);
});
it('transforms a non-absolute URL to an absolute URL', () => {
expect(absoluteUrlSchema.parse('example.com')).toBe('https://example.com');
expect(absoluteUrlSchema.parse('www.subdomain.example.com')).toBe(
'https://www.subdomain.example.com',
);
});
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);
});
});

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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();
});
});

View File

@ -0,0 +1,29 @@
import { isValidUrl } from '../isValidUrl';
describe('isValidUrl', () => {
it('test cases', () => {
// Truthy
expect(isValidUrl('https://www.example.com')).toBe(true);
expect(isValidUrl('http://192.168.2.0:3000')).toBe(true);
expect(isValidUrl('http://localhost')).toBe(true);
expect(isValidUrl('http://localhost:3000')).toBe(true);
expect(isValidUrl('http://subdomain.example.com')).toBe(true);
expect(isValidUrl('https://www.example.com/path')).toBe(true);
expect(isValidUrl('https://www.example.com/path/path2?query=123')).toBe(
true,
);
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);
});
});

View File

@ -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;
}
});

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -0,0 +1,5 @@
export * from './absoluteUrlSchema';
export * from './getAbsoluteUrlOrThrow';
export * from './getUrlHostnameOrThrow';
export * from './isValidHostname';
export * from './isValidUrl';

View File

@ -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;
};

View File

@ -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;
};