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'; } from './SessionCustomTagRenderer';
/** An array of supported html tags to render if found in a string */ /** 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 */ function createSupportedFormattingTagsRegex() {
const formattingTagRegex = new RegExp( return new RegExp(
`<(?:${supportedFormattingTags.join('|')})>.*?</(?:${supportedFormattingTags.join('|')})>`, `<(?:${supportedFormattingTags.join('|')})>.*?</(?:${supportedFormattingTags.join('|')})>|<(?:${supportedSelfClosingFormattingTags.join('|')})\\/>`,
'g' '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');
}
return str
.replace(new RegExp(`${identifier}&amp;${identifier}`, 'g'), '&')
.replace(new RegExp(`${identifier}&lt;${identifier}`, 'g'), '<')
.replace(new RegExp(`${identifier}&gt;${identifier}`, 'g'), '>');
}
const customTagRegex = new RegExp(`<(${supportedCustomTags.join('|')})/>`, '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 }>` const StyledHtmlRenderer = styled.span<{ isDarkTheme: boolean }>`
span { * > span {
color: ${props => (props.isDarkTheme ? 'var(--primary-color)' : 'var(--text-primary-color)')}; 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>) => { export const I18n = <T extends LocalizerToken>(props: I18nProps<T>) => {
const isDarkMode = useIsDarkTheme(); 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>) ...([props.token, i18nArgs] as GetMessageArgs<T>)
); );
const containsFormattingTag = i18nString.match(formattingTagRegex);
let startTag: CustomTag | null = null; let startTag: CustomTag | null = null;
let endTag: CustomTag | null = null; let endTag: CustomTag | null = null;
@ -69,7 +127,9 @@ export const I18n = <T extends LocalizerToken>(props: I18nProps<T>) => {
* @param group - The custom tag, without the angle brackets. * @param group - The custom tag, without the angle brackets.
* @param index - The index of the match in the string. * @param index - The index of the match in the string.
*/ */
i18nString.replace(customTagRegex, (match: string, group: CustomTag, index: number) => { i18nString = i18nString.replace(
createSupportedCustomTagsRegex(),
(match: string, group: CustomTag, index: number) => {
if (index === 0) { if (index === 0) {
startTag = group; startTag = group;
} else if (index === i18nString.length - match.length) { } else if (index === i18nString.length - match.length) {
@ -85,21 +145,20 @@ export const I18n = <T extends LocalizerToken>(props: I18nProps<T>) => {
} }
return ''; return '';
}); }
);
const content = containsFormattingTag ? ( const content = createSupportedFormattingTagsRegex().test(i18nString) ? (
/** If the string contains a relevant formatting tag, render it as HTML */ /** If the string contains a relevant formatting tag, render it as HTML */
<StyledHtmlRenderer isDarkTheme={isDarkMode}> <StyledHtmlRenderer isDarkTheme={isDarkMode}>
<SessionHtmlRenderer tag={props.as} html={i18nString} /> <SessionHtmlRenderer tag={props.asTag} html={i18nString} className={props.className} />
</StyledHtmlRenderer> </StyledHtmlRenderer>
) : ( ) : (
i18nString i18nString
); );
const Comp = props.as ?? Fragment;
return ( return (
<Comp> <Fragment>
{startTag ? ( {startTag ? (
<SessionCustomTagRenderer <SessionCustomTagRenderer
tag={startTag} tag={startTag}
@ -113,6 +172,6 @@ export const I18n = <T extends LocalizerToken>(props: I18nProps<T>) => {
tagProps={props.endTagProps as CustomTagProps<typeof endTag>} tagProps={props.endTagProps as CustomTagProps<typeof endTag>}
/> />
) : null} ) : null}
</Comp> </Fragment>
); );
}; };

@ -12,11 +12,11 @@ import {
subMilliseconds, subMilliseconds,
} from 'date-fns'; } from 'date-fns';
import timeLocales from 'date-fns/locale'; import timeLocales from 'date-fns/locale';
import { isUndefined } from 'lodash';
import { GetNetworkTime } from '../session/apis/snode_api/getNetworkTime'; 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 { updateLocale } from '../state/ducks/dictionary';
import { import {
ArgsRecordExcludingDefaults,
DictionaryWithoutPluralStrings, DictionaryWithoutPluralStrings,
GetMessageArgs, GetMessageArgs,
LocalizerDictionary, LocalizerDictionary,
@ -24,6 +24,8 @@ import {
PluralKey, PluralKey,
PluralString, PluralString,
} from '../types/Localizer'; } from '../types/Localizer';
import { deSanitizeHtmlTags, sanitizeArgs } from '../components/basic/I18n';
import { LOCALE_DEFAULTS } from '../localization/constants';
export function loadDictionary(locale: Locale) { export function loadDictionary(locale: Locale) {
return import(`../../_locales/${locale}/messages.json`) as Promise<LocalizerDictionary>; return import(`../../_locales/${locale}/messages.json`) as Promise<LocalizerDictionary>;
@ -125,6 +127,9 @@ function getStringForCardinalRule(
const isPluralForm = (localizedString: string): localizedString is PluralString => const isPluralForm = (localizedString: string): localizedString is PluralString =>
/{\w+, plural, one \[.+\] other \[.+\]}/g.test(localizedString); /{\w+, plural, one \[.+\] other \[.+\]}/g.test(localizedString);
const isStringWithArgs = (localizedString: string): localizedString is any =>
localizedString.includes('{');
/** /**
* Logs an i18n message to the console. * Logs an i18n message to the console.
* @param message - The message to log. * @param message - The message to log.
@ -137,6 +142,14 @@ function i18nLog(message: string) {
(window?.log?.error ?? console.log)(message); (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. * 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'); throw new Error('i18n: locale parameter is required');
} }
window.locale = locale;
if (!dictionary) { if (!dictionary) {
throw new Error('i18n: messages parameter is required'); throw new Error('i18n: messages parameter is required');
} }
@ -160,48 +175,21 @@ export const setupi18n = (locale: Locale, dictionary: LocalizerDictionary) => {
} }
i18nLog('i18n setup'); i18nLog('i18n setup');
/** function getRawMessage<T extends LocalizerToken, R extends DictionaryWithoutPluralStrings[T]>(
* 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> ...[token, args]: GetMessageArgs<T>
): R { ): R | T {
try { try {
const { if (window?.sessionFeatureFlags?.replaceLocalizedStringsWithKeys) {
inboxStore, return token as T;
sessionFeatureFlags: { replaceLocalizedStringsWithKeys },
} = window;
if (replaceLocalizedStringsWithKeys) {
return token as R;
} }
const storedDictionary = const localizedDictionary = getDictionary() ?? dictionary;
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`);
}
const localizedDictionary = storedDictionary ?? dictionary;
let localizedString = localizedDictionary[token] as R; let localizedString = localizedDictionary[token] as R;
if (!localizedString) { if (!localizedString) {
i18nLog(`i18n: Attempted to get translation for nonexistent key '${token}'`); 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 /** 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 { } else {
const num = args?.[pluralKey as keyof typeof args] ?? 0; 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); const pluralString = getStringForCardinalRule(localizedString, cardinalRule);
@ -229,37 +218,66 @@ export const setupi18n = (locale: Locale, dictionary: LocalizerDictionary) => {
i18nLog( i18nLog(
`i18n: Plural string not found for cardinal '${cardinalRule}': '${localizedString}'` `i18n: Plural string not found for cardinal '${cardinalRule}': '${localizedString}'`
); );
return token as R; return token as T;
} }
localizedString = pluralString.replaceAll('#', `${num}`) as R; localizedString = pluralString.replaceAll('#', `${num}`) as R;
} }
} }
return localizedString;
} catch (error) {
i18nLog(`i18n: ${error.message}`);
return token as T;
}
}
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 */ /** Find and replace the dynamic variables in a localized string and substitute the variables with the provided values */
return (localizedString as DictionaryWithoutPluralStrings[T]).replace( return rawMessage.replace(
/\{(\w+)\}/g, /\{(\w+)\}/g,
(match, arg) => { (match, arg) =>
const substitution: string | undefined = args?.[arg as keyof typeof args]; (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 formatMessageWithArgs<T, R>(rawMessage as any, args as any) as R;
return (substitution as string).toString();
}
) as R;
} catch (error) { } catch (error) {
i18nLog(`i18n: ${error.message}`); i18nLog(`i18n: ${error.message}`);
return token as R; 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. * 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]>( getMessage.stripped = <T extends LocalizerToken, R extends LocalizerDictionary[T]>(
...[token, args]: GetMessageArgs<T> ...[token, args]: GetMessageArgs<T>
): R => { ): R => {
const sanitizedArgs = args ? sanitizeArgs(args, '\u200B') : undefined;
const i18nString = getMessage<T, LocalizerDictionary[T]>( 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 getMessage; return deSanitizeHtmlTags(strippedString, '\u200B') as R;
}; };
export const getI18nFunction = (stripTags: boolean) => { getMessage.getRawMessage = getRawMessage;
return stripTags ? window.i18n.stripped : window.i18n; getMessage.formatMessageWithArgs = formatMessageWithArgs;
return getMessage;
}; };
// eslint-disable-next-line import/no-mutable-exports // eslint-disable-next-line import/no-mutable-exports
@ -298,7 +319,7 @@ export const loadEmojiPanelI18n = async () => {
return undefined; return undefined;
} }
const lang = window.getLocale(); const lang = getLocale();
if (lang !== 'en') { if (lang !== 'en') {
try { try {
const langData = await import(`@emoji-mart/data/i18n/${lang}.json`); const langData = await import(`@emoji-mart/data/i18n/${lang}.json`);
@ -315,14 +336,13 @@ export const loadEmojiPanelI18n = async () => {
return undefined; return undefined;
}; };
export const formatTimeDistance = ( export const formatTimeDuration = (
durationSeconds: number, durationMs: number,
baseDate: Date = new Date(0),
options?: Omit<FormatDistanceStrictOptions, 'locale'> 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], locale: timeLocaleMap[locale],
...options, ...options,
}); });
@ -456,7 +476,7 @@ export const formatTimeDistanceToNow = (
durationSeconds: number, durationSeconds: number,
options?: Omit<FormatDistanceToNowStrictOptions, 'locale'> options?: Omit<FormatDistanceToNowStrictOptions, 'locale'>
) => { ) => {
const locale = window.getLocale(); const locale = getLocale();
return formatDistanceToNowStrict(durationSeconds * 1000, { return formatDistanceToNowStrict(durationSeconds * 1000, {
locale: timeLocaleMap[locale], locale: timeLocaleMap[locale],
...options, ...options,
@ -464,7 +484,7 @@ export const formatTimeDistanceToNow = (
}; };
export const formatDateDistanceWithOffset = (date: Date): string => { export const formatDateDistanceWithOffset = (date: Date): string => {
const locale = window.getLocale(); const locale = getLocale();
const adjustedDate = subMilliseconds(date, GetNetworkTime.getLatestTimestampOffset()); const adjustedDate = subMilliseconds(date, GetNetworkTime.getLatestTimestampOffset());
return formatDistanceToNow(adjustedDate, { addSuffix: true, locale: timeLocaleMap[locale] }); return formatDistanceToNow(adjustedDate, { addSuffix: true, locale: timeLocaleMap[locale] });
}; };

3
ts/window.d.ts vendored

@ -49,7 +49,8 @@ declare global {
...[token, args]: GetMessageArgs<T> ...[token, args]: GetMessageArgs<T>
) => R; ) => 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; log: any;
sessionFeatureFlags: { sessionFeatureFlags: {
useOnionRequests: boolean; useOnionRequests: boolean;

Loading…
Cancel
Save