feat: remove html renderer from all places and use I18n with strict tag stripping

pull/3206/head
Ryan Miller 8 months ago
parent af28c97cfd
commit 702501c453

@ -17,18 +17,75 @@ import {
} from './SessionCustomTagRenderer';
/** An array of supported html tags to render if found in a string */
const supportedFormattingTags = ['b', 'i', 'u', 's', 'br', 'span'];
export const supportedFormattingTags = ['b', 'i', 'u', 's', 'br', 'span'];
/** NOTE: self-closing tags must also be listed in {@link supportedFormattingTags} */
const supportedSelfClosingFormattingTags = ['br'];
/** A regex to match supported formatting tags */
const formattingTagRegex = new RegExp(
`<(?:${supportedFormattingTags.join('|')})>.*?</(?:${supportedFormattingTags.join('|')})>`,
'g'
);
function createSupportedFormattingTagsRegex() {
return new RegExp(
`<(?:${supportedFormattingTags.join('|')})>.*?</(?:${supportedFormattingTags.join('|')})>|<(?:${supportedSelfClosingFormattingTags.join('|')})\\/>`,
'g'
);
}
function createSupportedCustomTagsRegex() {
return new RegExp(`<(${supportedCustomTags.join('|')})/>`, 'g');
}
/**
* Replaces all html tag identifiers with their escaped equivalents
* @param str The string to sanitize
* @param identifier The identifier to use for the args. Use this if you want to de-sanitize the args later.
* @returns The sanitized string
*/
export function sanitizeHtmlTags(str: string, identifier: string = ''): string {
if (identifier && /[a-zA-Z0-9></\\\-\s]+/g.test(identifier)) {
throw new Error('Identifier is not valid');
}
return str
.replace(/&/g, `${identifier}&amp;${identifier}`)
.replace(/</g, `${identifier}&lt;${identifier}`)
.replace(/>/g, `${identifier}&gt;${identifier}`);
}
/**
* Replaces all sanitized html tags with their real equivalents
* @param str The string to de-sanitize
* @param identifier The identifier used when the args were sanitized
* @returns The de-sanitized string
*/
export function deSanitizeHtmlTags(str: string, identifier: string): string {
if (!identifier || /[a-zA-Z0-9></\\\-\s]+/g.test(identifier)) {
throw new Error('Identifier is not valid');
}
const customTagRegex = new RegExp(`<(${supportedCustomTags.join('|')})/>`, 'g');
return str
.replace(new RegExp(`${identifier}&amp;${identifier}`, 'g'), '&')
.replace(new RegExp(`${identifier}&lt;${identifier}`, 'g'), '<')
.replace(new RegExp(`${identifier}&gt;${identifier}`, 'g'), '>');
}
/**
* Sanitizes the args to be used in the i18n function
* @param args The args to sanitize
* @param identifier The identifier to use for the args. Use this if you want to de-sanitize the args later.
* @returns The sanitized args
*/
export function sanitizeArgs(
args: Record<string, string | number>,
identifier?: string
): Record<string, string | number> {
return Object.fromEntries(
Object.entries(args).map(([key, value]) => [
key,
typeof value === 'string' ? sanitizeHtmlTags(value, identifier) : value,
])
);
}
const StyledHtmlRenderer = styled.span<{ isDarkTheme: boolean }>`
span {
* > span {
color: ${props => (props.isDarkTheme ? 'var(--primary-color)' : 'var(--text-primary-color)')};
}
`;
@ -53,14 +110,15 @@ const StyledHtmlRenderer = styled.span<{ isDarkTheme: boolean }>`
*/
export const I18n = <T extends LocalizerToken>(props: I18nProps<T>) => {
const isDarkMode = useIsDarkTheme();
const i18nArgs = 'args' in props ? props.args : undefined;
const containsFormattingTags = createSupportedFormattingTagsRegex().test(props.token);
const args = 'args' in props ? props.args : undefined;
const i18nArgs = args && containsFormattingTags ? sanitizeArgs(args) : args;
const i18nString = window.i18n<T, LocalizerDictionary[T]>(
let i18nString: string = window.i18n<T, LocalizerDictionary[T]>(
...([props.token, i18nArgs] as GetMessageArgs<T>)
);
const containsFormattingTag = i18nString.match(formattingTagRegex);
let startTag: CustomTag | null = null;
let endTag: CustomTag | null = null;
@ -69,37 +127,38 @@ export const I18n = <T extends LocalizerToken>(props: I18nProps<T>) => {
* @param group - The custom tag, without the angle brackets.
* @param index - The index of the match in the string.
*/
i18nString.replace(customTagRegex, (match: string, group: CustomTag, index: number) => {
if (index === 0) {
startTag = group;
} else if (index === i18nString.length - match.length) {
endTag = group;
} else {
/**
* If the match is not at the start or end of the string, throw an error.
* NOTE: This should never happen as this rule is enforced when the dictionary is generated.
*/
throw new Error(
`Custom tag ${group} (${match}) is not at the start or end (i=${index}) of the string: ${i18nString}`
);
}
i18nString = i18nString.replace(
createSupportedCustomTagsRegex(),
(match: string, group: CustomTag, index: number) => {
if (index === 0) {
startTag = group;
} else if (index === i18nString.length - match.length) {
endTag = group;
} else {
/**
* If the match is not at the start or end of the string, throw an error.
* NOTE: This should never happen as this rule is enforced when the dictionary is generated.
*/
throw new Error(
`Custom tag ${group} (${match}) is not at the start or end (i=${index}) of the string: ${i18nString}`
);
}
return '';
});
return '';
}
);
const content = containsFormattingTag ? (
const content = createSupportedFormattingTagsRegex().test(i18nString) ? (
/** If the string contains a relevant formatting tag, render it as HTML */
<StyledHtmlRenderer isDarkTheme={isDarkMode}>
<SessionHtmlRenderer tag={props.as} html={i18nString} />
<SessionHtmlRenderer tag={props.asTag} html={i18nString} className={props.className} />
</StyledHtmlRenderer>
) : (
i18nString
);
const Comp = props.as ?? Fragment;
return (
<Comp>
<Fragment>
{startTag ? (
<SessionCustomTagRenderer
tag={startTag}
@ -113,6 +172,6 @@ export const I18n = <T extends LocalizerToken>(props: I18nProps<T>) => {
tagProps={props.endTagProps as CustomTagProps<typeof endTag>}
/>
) : null}
</Comp>
</Fragment>
);
};

@ -12,11 +12,11 @@ import {
subMilliseconds,
} from 'date-fns';
import timeLocales from 'date-fns/locale';
import { isUndefined } from 'lodash';
import { GetNetworkTime } from '../session/apis/snode_api/getNetworkTime';
import { DURATION_SECONDS, LOCALE_DEFAULTS } from '../session/constants';
import { DURATION_SECONDS } from '../session/constants';
import { updateLocale } from '../state/ducks/dictionary';
import {
ArgsRecordExcludingDefaults,
DictionaryWithoutPluralStrings,
GetMessageArgs,
LocalizerDictionary,
@ -24,6 +24,8 @@ import {
PluralKey,
PluralString,
} from '../types/Localizer';
import { deSanitizeHtmlTags, sanitizeArgs } from '../components/basic/I18n';
import { LOCALE_DEFAULTS } from '../localization/constants';
export function loadDictionary(locale: Locale) {
return import(`../../_locales/${locale}/messages.json`) as Promise<LocalizerDictionary>;
@ -125,6 +127,9 @@ function getStringForCardinalRule(
const isPluralForm = (localizedString: string): localizedString is PluralString =>
/{\w+, plural, one \[.+\] other \[.+\]}/g.test(localizedString);
const isStringWithArgs = (localizedString: string): localizedString is any =>
localizedString.includes('{');
/**
* Logs an i18n message to the console.
* @param message - The message to log.
@ -137,6 +142,14 @@ function i18nLog(message: string) {
(window?.log?.error ?? console.log)(message);
}
export function getLocale(): Locale {
return window?.inboxStore?.getState().dictionary.locale ?? window?.locale;
}
function getDictionary(): LocalizerDictionary {
return window?.inboxStore?.getState().dictionary.dictionary;
}
/**
* Sets up the i18n function with the provided locale and messages.
*
@ -150,6 +163,8 @@ export const setupi18n = (locale: Locale, dictionary: LocalizerDictionary) => {
throw new Error('i18n: locale parameter is required');
}
window.locale = locale;
if (!dictionary) {
throw new Error('i18n: messages parameter is required');
}
@ -160,48 +175,21 @@ export const setupi18n = (locale: Locale, dictionary: LocalizerDictionary) => {
}
i18nLog('i18n setup');
/**
* Retrieves a localized message string, substituting variables where necessary.
*
* @param token - The token identifying the message to retrieve.
* @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic variables.
*
* @returns The localized message string with substitutions applied.
*
* @example
* // The string greeting is 'Hello, {name}!' in the current locale
* window.i18n('greeting', { name: 'Alice' });
* // => 'Hello, Alice!'
*/
function getMessage<T extends LocalizerToken, R extends LocalizerDictionary[T]>(
function getRawMessage<T extends LocalizerToken, R extends DictionaryWithoutPluralStrings[T]>(
...[token, args]: GetMessageArgs<T>
): R {
): R | T {
try {
const {
inboxStore,
sessionFeatureFlags: { replaceLocalizedStringsWithKeys },
} = window;
if (replaceLocalizedStringsWithKeys) {
return token as R;
}
const storedDictionary =
inboxStore && 'getState' in inboxStore && typeof inboxStore.getState === 'function'
? (inboxStore.getState().dictionary.dictionary as LocalizerDictionary)
: undefined;
if (!storedDictionary) {
i18nLog(`i18n: Stored dictionary not found, using setup dictionary as fallback`);
if (window?.sessionFeatureFlags?.replaceLocalizedStringsWithKeys) {
return token as T;
}
const localizedDictionary = storedDictionary ?? dictionary;
const localizedDictionary = getDictionary() ?? dictionary;
let localizedString = localizedDictionary[token] as R;
if (!localizedString) {
i18nLog(`i18n: Attempted to get translation for nonexistent key '${token}'`);
return token as R;
return token as T;
}
/** If a localized string does not have any arguments to substitute it is returned with no
@ -221,7 +209,8 @@ export const setupi18n = (locale: Locale, dictionary: LocalizerDictionary) => {
} else {
const num = args?.[pluralKey as keyof typeof args] ?? 0;
const cardinalRule = new Intl.PluralRules(locale).select(num);
const currentLocale = getLocale() ?? locale;
const cardinalRule = new Intl.PluralRules(currentLocale).select(num);
const pluralString = getStringForCardinalRule(localizedString, cardinalRule);
@ -229,37 +218,66 @@ export const setupi18n = (locale: Locale, dictionary: LocalizerDictionary) => {
i18nLog(
`i18n: Plural string not found for cardinal '${cardinalRule}': '${localizedString}'`
);
return token as R;
return token as T;
}
localizedString = pluralString.replaceAll('#', `${num}`) as R;
}
}
return localizedString;
} catch (error) {
i18nLog(`i18n: ${error.message}`);
return token as T;
}
}
/** Find and replace the dynamic variables in a localized string and substitute the variables with the provided values */
return (localizedString as DictionaryWithoutPluralStrings[T]).replace(
/\{(\w+)\}/g,
(match, arg) => {
const substitution: string | undefined = args?.[arg as keyof typeof args];
function formatMessageWithArgs<
T extends LocalizerToken,
R extends DictionaryWithoutPluralStrings[T],
>(rawMessage: R, args: ArgsRecordExcludingDefaults<T>): R {
/** Find and replace the dynamic variables in a localized string and substitute the variables with the provided values */
return rawMessage.replace(
/\{(\w+)\}/g,
(match, arg) =>
(args?.[arg as keyof typeof args] as string) ??
LOCALE_DEFAULTS[arg as keyof typeof LOCALE_DEFAULTS] ??
match
) as R;
}
if (isUndefined(substitution)) {
const defaultSubstitution = LOCALE_DEFAULTS[arg as keyof typeof LOCALE_DEFAULTS];
/**
* Retrieves a localized message string, substituting variables where necessary.
*
* @param token - The token identifying the message to retrieve.
* @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic variables.
*
* @returns The localized message string with substitutions applied.
*
* @example
* // The string greeting is 'Hello, {name}!' in the current locale
* window.i18n('greeting', { name: 'Alice' });
* // => 'Hello, Alice!'
*/
function getMessage<T extends LocalizerToken, R extends LocalizerDictionary[T]>(
...[token, args]: GetMessageArgs<T>
): R | T {
try {
const rawMessage = getRawMessage<T, R>(...([token, args] as GetMessageArgs<T>));
return isUndefined(defaultSubstitution) ? match : defaultSubstitution;
}
/** If a localized string does not have any arguments to substitute it is returned with no
* changes. We also need to check if the string contains a curly bracket as if it does
* there might be a default arg */
if (!args && !isStringWithArgs(rawMessage)) {
return rawMessage;
}
// TODO: figure out why is was type never and fix the type
return (substitution as string).toString();
}
) as R;
return formatMessageWithArgs<T, R>(rawMessage as any, args as any) as R;
} catch (error) {
i18nLog(`i18n: ${error.message}`);
return token as R;
}
}
window.getLocale = () => locale;
/**
* Retrieves a localized message string, substituting variables where necessary. Then strips the message of any HTML and custom tags.
*
@ -276,18 +294,21 @@ export const setupi18n = (locale: Locale, dictionary: LocalizerDictionary) => {
getMessage.stripped = <T extends LocalizerToken, R extends LocalizerDictionary[T]>(
...[token, args]: GetMessageArgs<T>
): R => {
const sanitizedArgs = args ? sanitizeArgs(args, '\u200B') : undefined;
const i18nString = getMessage<T, LocalizerDictionary[T]>(
...([token, args] as GetMessageArgs<T>)
...([token, sanitizedArgs] as GetMessageArgs<T>)
);
return i18nString.replaceAll(/<[^>]*>/g, '') as R;
const strippedString = i18nString.replaceAll(/<[^>]*>/g, '');
return deSanitizeHtmlTags(strippedString, '\u200B') as R;
};
return getMessage;
};
getMessage.getRawMessage = getRawMessage;
getMessage.formatMessageWithArgs = formatMessageWithArgs;
export const getI18nFunction = (stripTags: boolean) => {
return stripTags ? window.i18n.stripped : window.i18n;
return getMessage;
};
// eslint-disable-next-line import/no-mutable-exports
@ -298,7 +319,7 @@ export const loadEmojiPanelI18n = async () => {
return undefined;
}
const lang = window.getLocale();
const lang = getLocale();
if (lang !== 'en') {
try {
const langData = await import(`@emoji-mart/data/i18n/${lang}.json`);
@ -315,14 +336,13 @@ export const loadEmojiPanelI18n = async () => {
return undefined;
};
export const formatTimeDistance = (
durationSeconds: number,
baseDate: Date = new Date(0),
export const formatTimeDuration = (
durationMs: number,
options?: Omit<FormatDistanceStrictOptions, 'locale'>
) => {
const locale = window.getLocale();
const locale = getLocale();
return formatDistanceStrict(new Date(durationSeconds * 1000), baseDate, {
return formatDistanceStrict(new Date(durationMs), new Date(0), {
locale: timeLocaleMap[locale],
...options,
});
@ -456,7 +476,7 @@ export const formatTimeDistanceToNow = (
durationSeconds: number,
options?: Omit<FormatDistanceToNowStrictOptions, 'locale'>
) => {
const locale = window.getLocale();
const locale = getLocale();
return formatDistanceToNowStrict(durationSeconds * 1000, {
locale: timeLocaleMap[locale],
...options,
@ -464,7 +484,7 @@ export const formatTimeDistanceToNow = (
};
export const formatDateDistanceWithOffset = (date: Date): string => {
const locale = window.getLocale();
const locale = getLocale();
const adjustedDate = subMilliseconds(date, GetNetworkTime.getLatestTimestampOffset());
return formatDistanceToNow(adjustedDate, { addSuffix: true, locale: timeLocaleMap[locale] });
};

3
ts/window.d.ts vendored

@ -49,7 +49,8 @@ declare global {
...[token, args]: GetMessageArgs<T>
) => R;
};
getLocale: () => Locale;
/** NOTE: This locale is a readonly backup of the locale in the store. Use {@link getLocale} instead. */
locale: Readonly<Locale>;
log: any;
sessionFeatureFlags: {
useOnionRequests: boolean;

Loading…
Cancel
Save