snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

111
packages/tools/CHANGELOG.md Normal file
View File

@@ -0,0 +1,111 @@
# @rocket.chat/tools
## 0.4.0
### Minor Changes
- ([#41763](https://github.com/RocketChat/Rocket.Chat/pull/41763)) Drops `@rocket.chat/string-helpers` in favor of `@rocket.chat/tools`
## 0.4.0-rc.0
### Minor Changes
- ([#41763](https://github.com/RocketChat/Rocket.Chat/pull/41763)) Drops `@rocket.chat/string-helpers` in favor of `@rocket.chat/tools`
## 0.3.0
### Minor Changes
- ([#40096](https://github.com/RocketChat/Rocket.Chat/pull/40096)) Adds new function for censoring URL components in logs
## 0.3.0-rc.0
### Minor Changes
- ([#40096](https://github.com/RocketChat/Rocket.Chat/pull/40096)) Adds new function for censoring URL components in logs
## 0.2.5
### Patch Changes
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
## 0.2.5-rc.0
### Patch Changes
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
## 0.2.4
### Patch Changes
- ([#37852](https://github.com/RocketChat/Rocket.Chat/pull/37852) by [@lucas-a-pelegrino](https://github.com/lucas-a-pelegrino)) Adds improvements to the push notifications logic; the logic now truncates messages and titles larger than 240, and 65 characters respectively.
## 0.2.4-rc.0
### Patch Changes
- ([#37852](https://github.com/RocketChat/Rocket.Chat/pull/37852)) Adds improvements to the push notifications logic; the logic now truncates messages and titles larger than 240, and 65 characters respectively.
## 0.2.3
### Patch Changes
- ([#35006](https://github.com/RocketChat/Rocket.Chat/pull/35006)) Fixes the parsing of FreeSwitch events to properly generate a history of calls on Rocket.Chat
## 0.2.3-rc.0
### Patch Changes
- ([#35006](https://github.com/RocketChat/Rocket.Chat/pull/35006)) Fixes the parsing of FreeSwitch events to properly generate a history of calls on Rocket.Chat
## 0.2.2
### Patch Changes
- ([#32527](https://github.com/RocketChat/Rocket.Chat/pull/32527)) Fixed an inconsistent evaluation of the `Accounts_LoginExpiration` setting over the codebase. In some places, it was being used as milliseconds while in others as days. Invalid values produced different results. A helper function was created to centralize the setting validation and the proper value being returned to avoid edge cases.
Negative values may be saved on the settings UI panel but the code will interpret any negative, NaN or 0 value to the default expiration which is 90 days.
## 0.2.2-rc.0
### Patch Changes
- ([#32527](https://github.com/RocketChat/Rocket.Chat/pull/32527)) Fixed an inconsistent evaluation of the `Accounts_LoginExpiration` setting over the codebase. In some places, it was being used as milliseconds while in others as days. Invalid values produced different results. A helper function was created to centralize the setting validation and the proper value being returned to avoid edge cases.
Negative values may be saved on the settings UI panel but the code will interpret any negative, NaN or 0 value to the default expiration which is 90 days.
## 0.2.1
### Patch Changes
- ([#31138](https://github.com/RocketChat/Rocket.Chat/pull/31138)) feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo
## 0.2.1-rc.0
### Patch Changes
- b223cbde14: feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo
## 0.2.0
### Minor Changes
- 92613680b7: Added option to select between two script engine options for the integrations
## 0.2.0-rc.0
### Minor Changes
- 92613680b7: Added option to select between two script engine options for the integrations
## 0.1.0
### Minor Changes
- 1041d4d361: Added option to select between two script engine options for the integrations
## 0.1.0-rc.0
### Minor Changes
- 1041d4d361: Added option to select between two script engine options for the integrations

View File

@@ -0,0 +1,7 @@
import server from '@rocket.chat/jest-presets/server';
import type { Config } from 'jest';
export default {
preset: server.preset,
testMatch: ['<rootDir>/src/**/*.spec.(ts|js|mjs)'],
} satisfies Config;

View File

@@ -0,0 +1,31 @@
{
"name": "@rocket.chat/tools",
"version": "0.4.0",
"private": true,
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"files": [
"/dist"
],
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"test": "jest",
"test:cov": "jest --coverage",
"testunit": "jest",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/tsconfig": "workspace:*",
"@types/jest": "~30.0.0",
"eslint": "~9.39.5",
"jest": "~30.2.0",
"typescript": "~5.9.3"
},
"volta": {
"extends": "../../package.json"
}
}

View File

@@ -0,0 +1,39 @@
import { capitalize } from './capitalize';
describe('capitalize', () => {
it('should convert "xyz" to "Xyz"', () => {
expect(capitalize('xyz')).toBe('Xyz');
});
it('should convert "xyz xyz" to "Xyz xyz"', () => {
expect(capitalize('xyz xyz')).toBe('Xyz xyz');
});
it('should convert " xyz" to " xyz"', () => {
expect(capitalize(' xyz')).toBe(' xyz');
});
it('should convert undefined to ""', () => {
expect(capitalize(undefined as unknown as string)).toBe('');
});
it('should convert null to ""', () => {
expect(capitalize(null as unknown as string)).toBe('');
});
it('should convert false to ""', () => {
expect(capitalize(false as unknown as string)).toBe('');
});
it('should convert true to ""', () => {
expect(capitalize(true as unknown as string)).toBe('');
});
it('should convert 0 to ""', () => {
expect(capitalize(0 as unknown as string)).toBe('');
});
it('should convert 1 to ""', () => {
expect(capitalize(1 as unknown as string)).toBe('');
});
});

View File

@@ -0,0 +1,7 @@
export const capitalize = <T extends string>(s: T): Capitalize<T> => {
if (typeof s !== 'string') {
return '' as Capitalize<T>;
}
return (s.charAt(0).toUpperCase() + s.slice(1)) as Capitalize<T>;
};

View File

@@ -0,0 +1,53 @@
import { censorUrl } from './censorUrl';
describe('censorUrl', () => {
it('returns the original value when URL parsing fails', () => {
const input = 'not-a-url';
expect(censorUrl(input)).toBe(input);
});
it('returns relative URLs unchanged when no base is provided', () => {
const input = '/path/to/resource?query=secret&access_token=token';
expect(censorUrl(input)).toBe(input);
});
it('does not change URLs without sensitive parts', () => {
expect(censorUrl('https://example.com/path?foo=bar')).toBe('https://example.com/path?foo=bar');
});
it('redacts username and password from auth section', () => {
expect(censorUrl('https://user:password@example.com/path')).toBe('https://*Redacted*:*Redacted*@example.com/path');
});
it('redacts only username when password is not present', () => {
expect(censorUrl('https://user@example.com/path')).toBe('https://*Redacted*@example.com/path');
});
it('redacts query and access_token search params', () => {
expect(censorUrl('https://example.com/path?query=secret&access_token=token&foo=bar')).toBe(
'https://example.com/path?query=*Redacted*&access_token=*Redacted*&foo=bar',
);
});
it('redacts access_token even when query is absent', () => {
expect(censorUrl('https://example.com/path?access_token=token&foo=bar')).toBe(
'https://example.com/path?access_token=*Redacted*&foo=bar',
);
});
it('accepts URL objects as input', () => {
expect(censorUrl(new URL('https://user:password@example.com/path?query=secret'))).toBe(
'https://*Redacted*:*Redacted*@example.com/path?query=*Redacted*',
);
});
it('does not modify the original URL object', () => {
const input = new URL('https://user:password@example.com/path?query=secret&access_token=token');
const originalValue = input.toString();
expect(censorUrl(input)).toBe('https://*Redacted*:*Redacted*@example.com/path?query=*Redacted*&access_token=*Redacted*');
expect(input.toString()).toBe(originalValue);
});
});

View File

@@ -0,0 +1,45 @@
/**
* Redacts sensitive information from a URL string.
*
* This function parses a URL and replaces potentially sensitive data with placeholder text.
* It redacts the following URL components:
* - Username in the authentication section
* - Password in the authentication section
* - Query parameters named: 'query', 'access_token'
*
* Note: We use `*Redacted*` instead of `[Redacted]` for legibility, as `[` and `]` would be encoded by toString()
*
* @param url - The URL string to be censored
* @returns The URL string with sensitive information redacted, or the original URL if parsing fails
*
* @example
* ```ts
* censorUrl('https://user:password@example.com/path?query=secret&access_token=token');
* // Returns: 'https://*Redacted*:*Redacted*@example.com/path?query=*Redacted*&access_token=*Redacted*'
* ```
*/
export function censorUrl(url: string | URL): string {
try {
const parsedUrl = new URL(url);
if (parsedUrl.username) {
parsedUrl.username = '*Redacted*';
}
if (parsedUrl.password) {
parsedUrl.password = '*Redacted*';
}
if (parsedUrl.searchParams.has('query')) {
parsedUrl.searchParams.set('query', '*Redacted*');
}
if (parsedUrl.searchParams.has('access_token')) {
parsedUrl.searchParams.set('access_token', '*Redacted*');
}
return parsedUrl.toString();
} catch {
return url.toString();
}
}

View File

@@ -0,0 +1,36 @@
export function convertPathsIntoSubObjects(object: Record<string, any>): Record<string, any> {
const newObject: Record<string, any> = {};
for (const key of Object.keys(object)) {
const value = object[key];
if (!value && typeof value !== 'boolean') {
continue;
}
const keyProperties = key.split('.');
if (!keyProperties.length) {
continue;
}
let current = newObject;
const finalProperty = keyProperties.pop() as string;
for (const property of keyProperties) {
if (!(property in current) || typeof current[property] !== 'object') {
current[property] = {};
}
current = current[property];
}
if (current[finalProperty]) {
current[finalProperty] = {
...(typeof value === 'object' && value),
...current[finalProperty],
};
} else {
current[finalProperty] = value;
}
}
return newObject;
}

View File

@@ -0,0 +1,133 @@
import { expect } from 'chai';
import { convertSubObjectsIntoPaths } from './convertSubObjectsIntoPaths';
describe('convertSubObjectsIntoPaths', () => {
it('should flatten a simple object with no nested structure', () => {
const input = { a: 1, b: 2, c: 3 };
const expected = { a: 1, b: 2, c: 3 };
expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected);
});
it('should flatten a nested object into paths', () => {
const input = {
a: 1,
b: {
c: 2,
d: {
e: 3,
},
},
};
const expected = {
'a': 1,
'b.c': 2,
'b.d.e': 3,
};
expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected);
});
it('should handle objects with array values', () => {
const input = {
a: [1, 2, 3],
b: {
c: [4, 5],
},
};
const expected = {
'a': [1, 2, 3],
'b.c': [4, 5],
};
expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected);
});
it('should handle deeply nested objects', () => {
const input = {
a: {
b: {
c: {
d: {
e: {
f: 6,
},
},
},
},
},
};
const expected = {
'a.b.c.d.e.f': 6,
};
expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected);
});
it('should handle an empty object', () => {
const input = {};
const expected = {};
expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected);
});
it('should handle objects with mixed types of values', () => {
const input = {
a: 1,
b: 'string',
c: true,
d: {
e: null,
f: undefined,
g: {
h: 2,
},
},
};
const expected = {
'a': 1,
'b': 'string',
'c': true,
'd.e': null,
'd.f': undefined,
'd.g.h': 2,
};
expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected);
});
it('should respect the parentPath parameter', () => {
const input = {
a: 1,
b: {
c: 2,
},
};
const parentPath = 'root';
const expected = {
'root.a': 1,
'root.b.c': 2,
};
expect(convertSubObjectsIntoPaths(input, parentPath)).to.deep.equal(expected);
});
it('should not try to convert class instances', () => {
const input = {
a: new Date(),
b: {
c: new Date(),
},
};
const converted = convertSubObjectsIntoPaths(input);
expect(converted).not.to.be.undefined;
expect(converted).to.have.keys(['a', 'b.c']);
expect(converted.a).to.be.a('Date');
expect(converted['b.c']).to.be.a('Date');
});
});

View File

@@ -0,0 +1,17 @@
import { isRecord } from './isRecord';
export function convertSubObjectsIntoPaths(object: Record<string, any>, parentPath?: string): Record<string, any> {
return Object.fromEntries(
Object.keys(object).flatMap((key) => {
const value = object[key];
const fullKey = parentPath ? `${parentPath}.${key}` : key;
if (isRecord(value)) {
const flattened = convertSubObjectsIntoPaths(value, fullKey);
return Object.keys(flattened).map((newKey) => [newKey, flattened[newKey]]);
}
return [[fullKey, value]];
}) as [string, any][],
);
}

View File

@@ -0,0 +1,15 @@
import { convertFromDaysToMilliseconds } from './converter';
describe('convertFromDaysToMilliseconds', () => {
it('should throw an error when a non number is passed', () => {
// @ts-expect-error - Testing
expect(() => convertFromDaysToMilliseconds('90')).toThrow();
});
it('should return the value passed when its valid', () => {
expect(convertFromDaysToMilliseconds(85)).toBe(85 * 24 * 60 * 60 * 1000);
});
it('should fail if anything but an integer is passed', () => {
expect(() => convertFromDaysToMilliseconds(85.5)).toThrow();
expect(() => convertFromDaysToMilliseconds(-2.3)).toThrow();
});
});

View File

@@ -0,0 +1,7 @@
export const convertFromDaysToMilliseconds = (days: number) => {
if (typeof days !== 'number' || !Number.isInteger(days)) {
throw new Error('days must be a number');
}
return days * 24 * 60 * 60 * 1000;
};

View File

@@ -0,0 +1,17 @@
import { escapeHTML } from './escapeHTML';
describe('escapeHTML', () => {
it('works', () => {
expect(escapeHTML('<div>Blah & "blah" & \'blah\'</div>')).toBe(
'&lt;div&gt;Blah &amp; &quot;blah&quot; &amp; &#39;blah&#39;&lt;/div&gt;',
);
expect(escapeHTML('&lt;')).toBe('&amp;lt;');
expect(escapeHTML(' ')).toBe(' ');
expect(escapeHTML('¢')).toBe('&cent;');
expect(escapeHTML('¢ £ ¥ € © ® ™')).toBe('&cent; &pound; &yen; &euro; &copy; &reg; &trade;');
expect(escapeHTML(5 as unknown as string)).toBe('5');
expect(escapeHTML('')).toBe('');
expect(escapeHTML(null as unknown as string)).toBe('');
expect(escapeHTML(undefined as unknown as string)).toBe('');
});
});

View File

@@ -0,0 +1,24 @@
import { toString } from './toString';
const characterToHtmlEntityCode = {
'¢': 'cent',
'£': 'pound',
'¥': 'yen',
'€': 'euro',
'©': 'copy',
'®': 'reg',
'™': 'trade',
'<': 'lt',
'>': 'gt',
'"': 'quot',
'&': 'amp',
"'": '#39',
} as const;
const regex = new RegExp(`[${Object.keys(characterToHtmlEntityCode).join('')}]`, 'g');
const isEscapable = (char: string): char is keyof typeof characterToHtmlEntityCode => char in characterToHtmlEntityCode;
const escapeChar = (char: string): string => (isEscapable(char) ? `&${characterToHtmlEntityCode[char]};` : '');
export const escapeHTML = (str: string): string => toString(str).replace(regex, escapeChar);

View File

@@ -0,0 +1,56 @@
import { escapeRegExp } from './escapeRegExp';
describe('escapeRegExp', () => {
it('should keep strings with letters only unchanged', () => {
expect(escapeRegExp('word')).toBe('word');
});
it('should escape slashes', () => {
expect(escapeRegExp('/slashes/')).toBe('\\/slashes\\/');
expect(escapeRegExp('\\backslashes\\')).toBe('\\\\backslashes\\\\');
expect(escapeRegExp('\\border of word')).toBe('\\\\border of word');
});
it('should escape special group', () => {
expect(escapeRegExp('(?:non-capturing)')).toBe('\\(\\?\\:non\\-capturing\\)');
expect(new RegExp(`${escapeRegExp('(?:')}([^)]+)`).exec('(?:non-capturing)')?.[1]).toBe('non-capturing');
expect(escapeRegExp('(?=positive-lookahead)')).toBe('\\(\\?\\=positive\\-lookahead\\)');
expect(new RegExp(`${escapeRegExp('(?=')}([^)]+)`).exec('(?=positive-lookahead)')?.[1]).toBe('positive-lookahead');
expect(escapeRegExp('(?<=positive-lookbehind)')).toBe('\\(\\?<\\=positive\\-lookbehind\\)');
expect(new RegExp(`${escapeRegExp('(?<=')}([^)]+)`).exec('(?<=positive-lookbehind)')?.[1]).toBe('positive-lookbehind');
expect(escapeRegExp('(?!negative-lookahead)')).toBe('\\(\\?\\!negative\\-lookahead\\)');
expect(new RegExp(`${escapeRegExp('(?!')}([^)]+)`).exec('(?!negative-lookahead)')?.[1]).toBe('negative-lookahead');
expect(escapeRegExp('(?<!negative-lookbehind)')).toBe('\\(\\?<\\!negative\\-lookbehind\\)');
expect(new RegExp(`${escapeRegExp('(?<!')}([^)]+)`).exec('(?<!negative-lookbehind)')?.[1]).toBe('negative-lookbehind');
expect(escapeRegExp('[\\w]+')).toBe('\\[\\\\w\\]\\+');
expect(new RegExp(`${escapeRegExp('[')}([^\\]]+)`).exec('[character class]')?.[1]).toBe('character class');
expect(new RegExp(escapeRegExp('<div>')).exec('<td><div></td>')?.[0]).toBe('<div>');
expect(escapeRegExp('{5,2}')).toBe('\\{5,2\\}');
expect(escapeRegExp('/([.*+?^=!:${}()|[\\]\\/\\\\])/g')).toBe(
'\\/\\(\\[\\.\\*\\+\\?\\^\\=\\!\\:\\$\\{\\}\\(\\)\\|\\[\\\\\\]\\\\\\/\\\\\\\\\\]\\)\\/g',
);
});
it('should not escape whitespace', () => {
expect(escapeRegExp('\\n\\r\\t')).toBe('\\\\n\\\\r\\\\t');
expect(escapeRegExp('\n\r\t')).toBe('\n\r\t');
});
it('ignores errors from non-string argument', () => {
expect(() => escapeRegExp(false as any)).not.toThrow();
expect(() => (escapeRegExp as any)()).not.toThrow();
expect(() => escapeRegExp(null as any)).not.toThrow();
expect(() => escapeRegExp(42 as any)).not.toThrow();
});
});

View File

@@ -0,0 +1,3 @@
import { toString } from './toString';
export const escapeRegExp = (input: string): string => toString(input).replace(/[-.*+?^=!:${}()|[\]/\\]/g, '\\$&');

View File

@@ -0,0 +1,65 @@
import type { IncomingHttpHeaders } from 'node:http';
import { getHeader } from './getHeader';
describe('getHeader', () => {
describe('default mode (string)', () => {
it('returns empty string when headers is undefined', () => {
expect(getHeader(undefined as unknown as IncomingHttpHeaders, 'origin')).toBe('');
});
it('returns empty string when header does not exist', () => {
expect(getHeader({ origin: 'localhost:3000' }, 'host')).toBe('');
});
it('returns header value when it exists', () => {
expect(getHeader({ origin: 'localhost:3000' }, 'origin')).toBe('localhost:3000');
});
it('returns empty string when headers is empty object', () => {
expect(getHeader({}, 'origin')).toBe('');
});
it('returns empty string when value is undefined', () => {
expect(getHeader({ origin: undefined }, 'origin')).toBe('');
});
});
describe('generic array mode (string[])', () => {
it('returns the correct array when header is array', () => {
expect(getHeader<string[]>({ 'x-forwarded-for': ['127.0.0.1', '10.0.0.1'] }, 'x-forwarded-for')).toStrictEqual([
'127.0.0.1',
'10.0.0.1',
]);
});
it('returns empty array when value is empty array', () => {
expect(getHeader<string[]>({ 'x-forwarded-for': [] }, 'x-forwarded-for')).toStrictEqual([]);
});
it('returns string even when T is string[] (by design)', () => {
expect(getHeader<string[]>({ origin: 'localhost:3000' }, 'origin')).toEqual('localhost:3000');
});
});
describe('IncomingHttpHeaders support', () => {
it('works with IncomingHttpHeaders', () => {
const headers: IncomingHttpHeaders = {
connection: 'keep-alive',
};
expect(getHeader(headers, 'connection')).toBe('keep-alive');
expect(getHeader(headers, 'origin')).toBe('');
});
});
describe('Headers support', () => {
it('works with Headers', () => {
const headers = new Headers();
headers.set('host', 'localhost:3000');
expect(getHeader(headers, 'host')).toBe('localhost:3000');
expect(getHeader(headers, 'origin')).toBe('');
});
});
});

View File

@@ -0,0 +1,15 @@
import type { IncomingHttpHeaders } from 'node:http';
type HeaderLike = IncomingHttpHeaders | Headers | Record<string, string | string[] | undefined>;
export const getHeader = <T extends string | string[] = string>(headers: HeaderLike, key: string): T => {
if (!headers) {
return '' as T;
}
if (headers instanceof Headers) {
return (headers.get(key) ?? '') as T;
}
return (headers[key] ?? '') as T;
};

View File

@@ -0,0 +1,35 @@
import { getLoginExpirationInDays, getLoginExpirationInMs } from './getLoginExpiration';
describe('getLoginExpirationInDays', () => {
it('should return 90 by default', () => {
expect(getLoginExpirationInDays()).toBe(90);
});
it('should return 90 when value is 0', () => {
expect(getLoginExpirationInDays(0)).toBe(90);
});
it('should return 90 when value is NaN', () => {
expect(getLoginExpirationInDays(NaN)).toBe(90);
});
it('should return 90 when value is negative', () => {
expect(getLoginExpirationInDays(-1)).toBe(90);
});
it('should return 90 when value is undefined', () => {
expect(getLoginExpirationInDays(undefined)).toBe(90);
});
it('should return 90 when value is not a number', () => {
// @ts-expect-error - Testing
expect(getLoginExpirationInDays('90')).toBe(90);
});
it('should return the value passed when its valid', () => {
expect(getLoginExpirationInDays(85)).toBe(85);
});
});
describe('getLoginExpirationInMs', () => {
it('should return 90 days in milliseconds when no value is passed', () => {
expect(getLoginExpirationInMs()).toBe(90 * 24 * 60 * 60 * 1000);
});
it('should return the value passed when its valid', () => {
expect(getLoginExpirationInMs(85)).toBe(85 * 24 * 60 * 60 * 1000);
});
});

View File

@@ -0,0 +1,16 @@
import { convertFromDaysToMilliseconds } from './converter';
const ACCOUNTS_DEFAULT_LOGIN_EXPIRATION_DAYS = 90;
// Given a value, validates if it mets the conditions to be a valid login expiration.
// Else, returns the default login expiration (which for Meteor is 90 days)
export const getLoginExpirationInDays = (expiry?: number) => {
if (expiry && typeof expiry === 'number' && !Number.isNaN(expiry) && expiry > 0) {
return expiry;
}
return ACCOUNTS_DEFAULT_LOGIN_EXPIRATION_DAYS;
};
export const getLoginExpirationInMs = (expiry?: number) => {
return convertFromDaysToMilliseconds(getLoginExpirationInDays(expiry));
};

View File

@@ -0,0 +1 @@
export const getObjectKeys = <T extends object>(object: T) => Object.keys(object) as (keyof T)[];

View File

@@ -0,0 +1,24 @@
export * from './capitalize';
export * from './censorUrl';
export * from './converter';
export * from './convertPathsIntoSubObjects';
export * from './convertSubObjectsIntoPaths';
export * from './escapeHTML';
export * from './escapeRegExp';
export * from './getHeader';
export * from './getLoginExpiration';
export * from './getObjectKeys';
export * from './isAbsoluteURL';
export * from './isObject';
export * from './isRecord';
export * from './isTruthy';
export * from './normalizeLanguage';
export * from './objectMap';
export * from './pick';
export * from './removeEmpty';
export * from './stream';
export * from './timezone';
export * from './truncateString';
export * from './unescapeHTML';
export * from './validateEmail';
export * from './wrapExceptions';

View File

@@ -0,0 +1,27 @@
import { isAbsoluteURL } from './isAbsoluteURL';
describe('isAbsoluteURL', () => {
test.each([
['/', false],
['test', false],
['test/test', false],
['.', false],
['./test', false],
['/absolute/path', false],
['relative/path?query=1', false],
['ftp://example.com', false],
])('should return false for non-absolute URL %# (%s)', (input, expected) => {
expect(isAbsoluteURL(input)).toBe(expected);
});
test.each([
['https://rocket.chat', true],
['http://rocket.chat', true],
['https://example.com/path?query=1#hash', true],
['http://localhost:3000', true],
['data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', true],
['data:text/plain;charset=utf-8,Hello', true],
])('should return true for absolute URL %# (%s)', (input, expected) => {
expect(isAbsoluteURL(input)).toBe(expected);
});
});

View File

@@ -0,0 +1 @@
export const isAbsoluteURL = (str: string): boolean => /^(https?:\/\/|data:)/.test(str);

View File

@@ -0,0 +1,4 @@
export function isObject(obj: unknown): obj is object {
const type = typeof obj;
return type === 'function' || (type === 'object' && !!obj);
}

View File

@@ -0,0 +1,48 @@
import { isRecord } from './isRecord';
describe('isRecord', () => {
test.each([
['case-1', undefined, false],
['case-2', null, false],
['case-3', 0, false],
['case-4', false, false],
['case-5', '', false],
])('should return false for falsy values %# (%s)', (_caseId, input, expected) => {
expect(isRecord(input)).toEqual(expected);
});
test.each([
['case-1', undefined, false],
['case-2', null, false],
['case-3', 20, false],
['case-4', true, false],
['case-5', 'string', false],
])('should return false for non-objects %# (%s)', (_caseId, input, expected) => {
expect(isRecord(input)).toEqual(expected);
});
test.each([
['case-1', {}, true],
['case-2', { prop: 'value' }, true],
['case-3', Object.fromEntries([]), true],
['case-4', Object.create(null), true],
['case-5', { 0: 'zero', 1: 'one' }, true],
['case-6', { [Symbol('key')]: 'value' }, true],
// we testing, we ignore eslint
// eslint-disable-next-line no-new-wrappers
['case-7', new String('string'), false],
// eslint-disable-next-line no-new-wrappers
['case-8', new Number(1), false],
['case-9', [], false],
])('should return true for records %# (%s)', (_caseId, input, expected) => {
expect(isRecord(input)).toEqual(expected);
});
test.each([
['case-1', new Date(), false],
['case-2', [], false],
['case-2', [{}], false],
])('should return false for objects that are not records %# (%s)', (_caseId, input, expected) => {
expect(isRecord(input)).toEqual(expected);
});
});

View File

@@ -0,0 +1,17 @@
export function isRecord(record: unknown): record is Record<string | number | symbol, unknown> {
if (!record || typeof record !== 'object') {
return false;
}
if (Array.isArray(record)) {
return false;
}
const prototype = Object.getPrototypeOf(record);
if (prototype === null) {
return true;
}
return prototype.constructor === Object;
}

View File

@@ -0,0 +1 @@
export const isTruthy = <T>(x: T | null | undefined | 0 | false | ''): x is T => Boolean(x);

View File

@@ -0,0 +1,10 @@
export const normalizeLanguage = (language: string): string => {
// Fix browsers having all-lowercase language settings eg. pt-br, en-us
const regex = /([a-z]{2,3})-([a-z]{2,4})/;
const matches = regex.exec(language);
if (matches) {
return `${matches[1]}-${matches[2].toUpperCase()}`;
}
return language;
};

View File

@@ -0,0 +1,93 @@
import { expect } from 'chai';
import { objectMap } from './objectMap';
describe('objectMap', () => {
it('should map a simple object non-recursively', () => {
const input = { a: 1, b: 2, c: 3 };
const callback = ({ key, value }) => ({ key: key.toUpperCase(), value: value * 2 });
const expected = { A: 2, B: 4, C: 6 };
expect(objectMap(input, callback)).to.deep.equal(expected);
});
it('should filter out undefined results from callback', () => {
const input = { a: 1, b: 2, c: 3 };
const callback = ({ key, value }) => (value > 1 ? { key, value } : undefined);
const expected = { b: 2, c: 3 };
expect(objectMap(input, callback)).to.deep.equal(expected);
});
it('should map a nested object recursively', () => {
const input = {
a: 1,
b: {
c: 2,
d: {
e: 3,
},
},
};
const callback = ({ key, value }) => ({ key: `mapped_${key}`, value: typeof value === 'number' ? value * 10 : value });
const expected = {
mapped_a: 10,
mapped_b: {
mapped_c: 20,
mapped_d: {
mapped_e: 30,
},
},
};
expect(objectMap(input, callback, true)).to.deep.equal(expected);
});
it('should handle an empty object', () => {
const input = {};
const callback = ({ key, value }) => ({ key: `mapped_${key}`, value });
const expected = {};
expect(objectMap(input, callback)).to.deep.equal(expected);
});
it('should handle mixed value types in non-recursive mode', () => {
const input = {
a: 1,
b: 'string',
c: true,
d: null,
};
const callback = ({ key, value }) => ({ key: key.toUpperCase(), value: typeof value === 'number' ? value * 2 : value });
const expected = {
A: 2,
B: 'string',
C: true,
D: null,
};
expect(objectMap(input, callback)).to.deep.equal(expected);
});
it('should handle nested objects with mixed types recursively', () => {
const input = {
a: 1,
b: {
c: 'string',
d: {
e: true,
f: null,
},
},
};
const callback = ({ key, value }) => ({ key: key.toUpperCase(), value });
const expected = {
A: 1,
B: {
C: 'string',
D: {
E: true,
F: null,
},
},
};
expect(objectMap(input, callback, true)).to.deep.equal(expected);
});
it('should not modify the original object', () => {
const input = { a: 1, b: 2 };
const original = { ...input };
const callback = ({ key, value }) => ({ key, value: value * 2 });
objectMap(input, callback);
expect(input).to.deep.equal(original);
});
});

View File

@@ -0,0 +1,35 @@
export function objectMap<TObject extends Record<string, any> = Record<string, any>, K extends keyof TObject | string = keyof TObject>(
object: TObject,
cb: (value: { key: K; value: TObject[K] }) => { key: string | number | symbol; value: any } | undefined,
recursive?: false,
): Record<string, any>;
export function objectMap<TObject extends Record<string, any> = Record<string, any>>(
object: TObject,
cb: (value: { key: string | number | symbol; value: any }) => { key: string | number | symbol; value: any } | undefined,
recursive: true,
): Record<string, any>;
export function objectMap<TObject extends Record<string, any> = Record<string, any>, K extends keyof TObject | string = keyof TObject>(
object: TObject,
cb: (value: { key: K; value: any }) => { key: string | number | symbol; value: any } | undefined,
recursive: false,
): Record<string, any>;
export function objectMap<TObject extends Record<string, any> = Record<string, any>, K extends keyof TObject | string = keyof TObject>(
object: TObject,
cb: (value: { key: K | string; value: any }) => { key: string | number | symbol; value: any } | undefined,
recursive = false,
): Record<string, any> {
return Object.fromEntries(
Object.keys(object)
.map((key) => {
const value = object[key as K];
if (recursive && value && typeof value === 'object' && !Array.isArray(value) && !((value as any) instanceof Date)) {
const newValue = objectMap(value, cb as any, true);
return cb({ key, value: newValue });
}
return cb({ key, value });
})
.filter((item) => !!item)
.map((item) => [item.key, item.value]),
);
}

View File

@@ -0,0 +1,14 @@
export function pick<TObject extends Record<string, any>, TKey extends keyof TObject>(
object: TObject,
...attributes: TKey[]
): Pick<TObject, TKey> {
return {
...attributes.reduce(
(data, key) => ({
...data,
...(key in object ? { [key]: object[key] } : {}),
}),
{} as TObject,
),
};
}

View File

@@ -0,0 +1,87 @@
import { removeEmpty } from './removeEmpty';
describe('removeEmpty', () => {
it('should remove null props', () => {
const obj = { a: 1, b: null };
expect(removeEmpty(obj)).toEqual({ a: 1 });
});
it('should remove undefined props', () => {
const obj = { a: 1, b: undefined };
expect(removeEmpty(obj)).toEqual({ a: 1 });
});
it('should not remove empty strings', () => {
const obj = { a: 1, b: '' };
expect(removeEmpty(obj)).toEqual({ a: 1, b: '' });
});
it('should not remove empty arrays', () => {
const obj = { a: 1, b: [] };
expect(removeEmpty(obj)).toEqual({ a: 1, b: [] });
});
it('should not remove empty objects', () => {
const obj = { a: 1, b: {} };
expect(removeEmpty(obj)).toEqual({ a: 1, b: {} });
});
it('should not remove 0', () => {
const obj = { a: 1, b: 0 };
expect(removeEmpty(obj)).toEqual({ a: 1, b: 0 });
});
it('should not remove false', () => {
const obj = { a: 1, b: false };
expect(removeEmpty(obj)).toEqual({ a: 1, b: false });
});
it('should not remove NaN', () => {
const obj = { a: 1, b: NaN };
expect(removeEmpty(obj)).toEqual({ a: 1, b: NaN });
});
it('should not remove Infinity', () => {
const obj = { a: 1, b: Infinity };
expect(removeEmpty(obj)).toEqual({ a: 1, b: Infinity });
});
it('should not remove functions', () => {
const fn = () => {
// noop
};
const obj = {
a: 1,
fn,
};
expect(removeEmpty(obj)).toEqual({
a: 1,
fn,
});
});
it('should not remove symbols', () => {
const b = Symbol('test');
const obj = { a: 1, b };
expect(removeEmpty(obj)).toEqual({ a: 1, b });
});
it('should not remove objects with non-empty props', () => {
const obj = { a: 1, b: { c: 2 } };
expect(removeEmpty(obj)).toEqual({ a: 1, b: { c: 2 } });
});
});

View File

@@ -0,0 +1,7 @@
type NonEmpty<T> = {
[K in keyof T]: Exclude<T[K], null | undefined>;
};
export function removeEmpty<T extends Record<string, any>>(obj: T): NonEmpty<T> {
return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v != null)) as NonEmpty<T>;
}

View File

@@ -0,0 +1,13 @@
import type { Readable } from 'node:stream';
export const streamToBuffer = (stream: Readable): Promise<Buffer> =>
new Promise((resolve, reject) => {
const chunks: Array<Buffer> = [];
stream
.on('data', (data) => chunks.push(data))
.on('end', () => resolve(Buffer.concat(chunks)))
.on('error', (error) => reject(error))
// force stream to resume data flow in case it was explicitly paused before
.resume();
});

View File

@@ -0,0 +1,57 @@
import { canonicalizeTimezone, getTimezoneNames } from './timezone';
describe('canonicalizeTimezone', () => {
it('returns the same value for a canonical IANA zone', () => {
expect(canonicalizeTimezone('America/Sao_Paulo')).toBe('America/Sao_Paulo');
});
it('resolves plain UTC to UTC', () => {
expect(canonicalizeTimezone('UTC')).toBe('UTC');
});
it('resolves Etc/UTC and Etc/GMT to UTC', () => {
expect(canonicalizeTimezone('Etc/UTC')).toBe('UTC');
expect(canonicalizeTimezone('Etc/GMT')).toBe('UTC');
});
it('resolves legacy moment aliases to their canonical zone', () => {
expect(canonicalizeTimezone('GMT')).toBe('UTC');
expect(canonicalizeTimezone('Zulu')).toBe('UTC');
expect(canonicalizeTimezone('Universal')).toBe('UTC');
expect(canonicalizeTimezone('US/Pacific')).toBe('America/Los_Angeles');
expect(canonicalizeTimezone('Japan')).toBe('Asia/Tokyo');
});
it('resolves legacy IANA names to modern canonical names', () => {
expect(canonicalizeTimezone('Asia/Calcutta')).toBe('Asia/Kolkata');
expect(canonicalizeTimezone('Asia/Katmandu')).toBe('Asia/Kathmandu');
expect(canonicalizeTimezone('Asia/Rangoon')).toBe('Asia/Yangon');
expect(canonicalizeTimezone('Asia/Saigon')).toBe('Asia/Ho_Chi_Minh');
expect(canonicalizeTimezone('Europe/Kiev')).toBe('Europe/Kyiv');
expect(canonicalizeTimezone('America/Godthab')).toBe('America/Nuuk');
expect(canonicalizeTimezone('Pacific/Enderbury')).toBe('Pacific/Kanton');
});
it('preserves modern canonical names as-is', () => {
expect(canonicalizeTimezone('Asia/Kolkata')).toBe('Asia/Kolkata');
expect(canonicalizeTimezone('Europe/Kyiv')).toBe('Europe/Kyiv');
expect(canonicalizeTimezone('Asia/Ho_Chi_Minh')).toBe('Asia/Ho_Chi_Minh');
});
it('returns the input unchanged when it is not a recognized zone', () => {
const input = 'Not/A_Zone';
expect(canonicalizeTimezone(input)).toBe(input);
});
});
describe('getTimezoneNames', () => {
it('returns modern canonical names instead of legacy ones', () => {
const names = getTimezoneNames();
expect(names).toContain('Asia/Kolkata');
expect(names).not.toContain('Asia/Calcutta');
expect(names).toContain('Europe/Kyiv');
expect(names).not.toContain('Europe/Kiev');
expect(names).toContain('Asia/Yangon');
expect(names).not.toContain('Asia/Rangoon');
});
});

View File

@@ -0,0 +1,76 @@
// Zones where Node/browser Intl returns a legacy IANA name instead of the
// current canonical one. Workaround until Temporal lands (~late 2026).
// Source: https://data.iana.org/time-zones/tzdb/backward
// Ref: https://github.com/tc39/proposal-temporal/issues/3249
const LEGACY_TO_CANONICAL: Record<string, string> = {
'America/Buenos_Aires': 'America/Argentina/Buenos_Aires',
'America/Catamarca': 'America/Argentina/Catamarca',
'America/Cordoba': 'America/Argentina/Cordoba',
'America/Godthab': 'America/Nuuk',
'America/Indianapolis': 'America/Indiana/Indianapolis',
'America/Jujuy': 'America/Argentina/Jujuy',
'America/Louisville': 'America/Kentucky/Louisville',
'America/Mendoza': 'America/Argentina/Mendoza',
'Asia/Calcutta': 'Asia/Kolkata',
'Asia/Katmandu': 'Asia/Kathmandu',
'Asia/Rangoon': 'Asia/Yangon',
'Asia/Saigon': 'Asia/Ho_Chi_Minh',
'Atlantic/Faeroe': 'Atlantic/Faroe',
'Europe/Kiev': 'Europe/Kyiv',
'Pacific/Enderbury': 'Pacific/Kanton',
};
export const canonicalizeTimezone = (name: string): string => {
try {
const resolved = new Intl.DateTimeFormat(undefined, { timeZone: name }).resolvedOptions().timeZone;
return LEGACY_TO_CANONICAL[resolved] ?? resolved;
} catch {
return name;
}
};
export const getTimezoneNames = (): string[] => {
const intl = Intl as typeof Intl & { supportedValuesOf?(key: 'timeZone'): string[] };
const zones = typeof intl.supportedValuesOf === 'function' ? intl.supportedValuesOf('timeZone') : [];
return zones.map((name) => LEGACY_TO_CANONICAL[name] ?? name).sort();
};
export const guessTimezoneFromOffset = (offset: string | number): string => {
const hours = Number(offset);
const totalMinutes = Math.round(hours * 60);
const supportedZones = Intl.supportedValuesOf('timeZone');
const now = new Date();
for (const tz of supportedZones) {
// Skip synthetic Etc/ zones — prefer geographic names (DST-aware)
if (tz.startsWith('Etc/')) {
continue;
}
const formatter = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' });
const parts = formatter.formatToParts(now);
const tzPart = parts.find((p) => p.type === 'timeZoneName')?.value ?? '';
// tzPart looks like "GMT", "GMT+5:30", "GMT-3"
const match = tzPart.match(/^GMT([+-]\d{1,2}(?::(\d{2}))?)?$/);
if (!match) {
continue;
}
const tzHours = match[1] ? parseInt(match[1], 10) : 0;
const tzMinutes = match[2] ? parseInt(match[2], 10) * (tzHours < 0 ? -1 : 1) : 0;
if (tzHours * 60 + tzMinutes === totalMinutes) {
return LEGACY_TO_CANONICAL[tz] ?? tz;
}
}
// Fallback to Etc/GMT when no geographic zone matches
const intHours = Math.trunc(hours);
if (intHours === 0) {
return 'Etc/GMT';
}
return `Etc/GMT${intHours > 0 ? '-' : '+'}${Math.abs(intHours)}`;
};
export const guessTimezone = (): string => {
const resolved = new Intl.DateTimeFormat().resolvedOptions().timeZone;
return LEGACY_TO_CANONICAL[resolved] ?? resolved;
};

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions
export const toString = (object: unknown): string => (object ? `${object}` : '');

View File

@@ -0,0 +1,23 @@
/**
* Truncates a string to a specified maximum length, optionally adding ellipses.
* @param str
* @param maxLength
* @param shouldAddEllipses
* @return {string}
*/
export function truncateString(str: string, maxLength: number, shouldAddEllipses = true): string {
const ellipsis = '...';
if (str.length <= maxLength) {
return str;
}
if (shouldAddEllipses && str.length > maxLength) {
if (maxLength <= ellipsis.length) {
return str.slice(0, maxLength);
}
return `${str.slice(0, maxLength - ellipsis.length)}${ellipsis}`;
}
return str.slice(0, maxLength);
}

View File

@@ -0,0 +1,30 @@
import { unescapeHTML } from './unescapeHTML';
describe('unescapeHTML', () => {
it('works', () => {
expect(unescapeHTML('&lt;div&gt;Blah &amp; &quot;blah&quot; &amp; &apos;blah&#39;&lt;/div&gt;')).toBe(
'<div>Blah & "blah" & \'blah\'</div>',
);
expect(unescapeHTML('&amp;lt;')).toBe('&lt;');
expect(unescapeHTML('&apos;')).toBe("'");
expect(unescapeHTML('&#39;')).toBe("'");
expect(unescapeHTML('&#0039;')).toBe("'");
expect(unescapeHTML('&#x4a;')).toBe('J');
expect(unescapeHTML('&#x04A;')).toBe('J');
expect(unescapeHTML('&#X4A;')).toBe('&#X4A;');
expect(unescapeHTML('&_#39;')).toBe('&_#39;');
expect(unescapeHTML('&#39_;')).toBe('&#39_;');
expect(unescapeHTML('&amp;#38;')).toBe('&#38;');
expect(unescapeHTML('&#38;amp;')).toBe('&amp;');
expect(unescapeHTML('')).toBe('');
expect(unescapeHTML('&nbsp;')).toBe(' ');
expect(unescapeHTML('what is the &yen; to &pound; to &euro; conversion process?')).toBe('what is the ¥ to £ to € conversion process?');
expect(unescapeHTML('&reg; trademark')).toBe('® trademark');
expect(unescapeHTML('&trade; unregistered trademark')).toBe('™ unregistered trademark');
expect(unescapeHTML('&copy; 1992. License available for 50 &cent;')).toBe('© 1992. License available for 50 ¢');
expect(unescapeHTML(null as unknown as string)).toBe('');
expect(unescapeHTML(undefined as unknown as string)).toBe('');
expect(unescapeHTML(5 as unknown as string)).toBe('5');
});
});

View File

@@ -0,0 +1,41 @@
import { toString } from './toString';
const htmlEntityCodeToCharacter = {
nbsp: ' ',
cent: '¢',
pound: '£',
yen: '¥',
euro: '€',
copy: '©',
reg: '®',
trade: '™',
lt: '<',
gt: '>',
quot: '"',
amp: '&',
apos: "'",
} as const;
const isHtmlEntityCode = (htmlEntityCode: string): htmlEntityCode is keyof typeof htmlEntityCodeToCharacter =>
htmlEntityCode in htmlEntityCodeToCharacter;
export const unescapeHTML = (str: string): string =>
toString(str).replace(/\&([^;]{1,10});/g, (entity, htmlEntityCode) => {
let match;
if (isHtmlEntityCode(htmlEntityCode)) {
return htmlEntityCodeToCharacter[htmlEntityCode];
}
match = htmlEntityCode.match(/^#x([\da-fA-F]+)$/);
if (match) {
return String.fromCharCode(parseInt(match[1], 16));
}
match = htmlEntityCode.match(/^#(\d+)$/);
if (match) {
return String.fromCharCode(~~match[1]);
}
return entity;
});

View File

@@ -0,0 +1,13 @@
export const validateEmail = (email: string, options: { style: string } = { style: 'basic' }): boolean => {
const basicEmailRegex = /^[^@]+@[^@]+$/;
const rfcEmailRegex =
/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
switch (options.style) {
case 'rfc':
return rfcEmailRegex.test(email);
case 'basic':
default:
return basicEmailRegex.test(email);
}
};

View File

@@ -0,0 +1,47 @@
const isPromise = <T>(value: unknown): value is Promise<T> => !!value && value instanceof Promise;
export function wrapExceptions<T = any>(
getter: () => T,
): {
catch: (errorWrapper: (error: any) => T) => T;
suppress: (errorWrapper?: (error: any) => void) => T | undefined;
};
export function wrapExceptions<T = any>(
getter: () => Promise<T>,
): {
catch: (errorWrapper: (error: any) => T | Awaited<T>) => Promise<T>;
suppress: (errorWrapper?: (error: any) => void) => Promise<T | undefined>;
};
export function wrapExceptions<T>(getter: () => T) {
const doCatch = (errorWrapper: (error: any) => T | Awaited<T>): T => {
try {
const value = getter();
if (isPromise(value)) {
return value.catch(errorWrapper) as T;
}
return value;
} catch (error) {
return errorWrapper(error);
}
};
const doSuppress = (errorWrapper?: (error: any) => void) => {
try {
const value = getter();
if (isPromise(value)) {
return value.catch((error) => errorWrapper?.(error));
}
return value;
} catch (error) {
errorWrapper?.(error);
return undefined;
}
};
return {
catch: doCatch,
suppress: doSuppress,
};
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["./**/*.spec.ts"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "@rocket.chat/tsconfig/server.json",
"compilerOptions": {
"declaration": true,
"rootDir": "./src",
"outDir": "./dist",
"lib": ["es2022"]
},
"include": ["./src/**/*"]
}