From ad63577792068d3992056cc9294235c2bebb6fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 30 Jul 2025 19:50:41 +0200 Subject: [PATCH] pl-fe: suspicious link warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../pl-fe/src/components/parsed-content.tsx | 32 +- .../src/components/ui/multiselect.tsx.bak | 533 ++++++++++++++++++ packages/pl-fe/src/locales/en.json | 5 +- 3 files changed, 567 insertions(+), 3 deletions(-) create mode 100644 packages/pl-fe/src/components/ui/multiselect.tsx.bak diff --git a/packages/pl-fe/src/components/parsed-content.tsx b/packages/pl-fe/src/components/parsed-content.tsx index ec087529e..f9cd78863 100644 --- a/packages/pl-fe/src/components/parsed-content.tsx +++ b/packages/pl-fe/src/components/parsed-content.tsx @@ -4,6 +4,7 @@ import DOMPurify from 'isomorphic-dompurify'; import groupBy from 'lodash/groupBy'; import minBy from 'lodash/minBy'; import React from 'react'; +import { FormattedMessage } from 'react-intl'; import { Link } from 'react-router-dom'; import Emojify from 'pl-fe/features/emoji/emojify'; @@ -20,6 +21,15 @@ import type { CustomEmoji, Mention } from 'pl-api'; const GREENTEXT_CLASS = 'dark:text-accent-green text-lime-600'; +const checkSuspiciousUrl = (url: string): boolean => { + try { + const { host } = new URL(url); + return /^verify\.form/.test(host); + } catch (e) { + return false; + } +}; + const nodesToText = (nodes: Array): string => nodes.map(node => node.type === 'text' ? node.data : node.type === 'tag' ? nodesToText(node.children as Array) : '').join(''); @@ -173,6 +183,8 @@ function parseContent({ const hashtags: Array = []; + let hasSuspiciousUrl = false; + const transformText = (data: string, key?: React.Key) => { const text = speakAsCat ? nyaize(data) : data; @@ -264,6 +276,10 @@ function parseContent({ } } + if (checkSuspiciousUrl(domNode.attribs.href)) { + hasSuspiciousUrl = true; + } + return fallback; } @@ -296,7 +312,21 @@ function parseContent({ }, }; - const content = parse(DOMPurify.sanitize(html, { ADD_ATTR: ['target'], USE_PROFILES: { html: true } }), options); + let content = parse(DOMPurify.sanitize(html, { ADD_ATTR: ['target'], USE_PROFILES: { html: true } }), options); + + if (hasSuspiciousUrl) { + content = ( + <> +
+ +
+ {content} + + ); + } if (extractHashtags) return { content, diff --git a/packages/pl-fe/src/components/ui/multiselect.tsx.bak b/packages/pl-fe/src/components/ui/multiselect.tsx.bak new file mode 100644 index 000000000..d9e6c3aba --- /dev/null +++ b/packages/pl-fe/src/components/ui/multiselect.tsx.bak @@ -0,0 +1,533 @@ +import React, { useRef, useEffect } from 'react'; + +import Icon from './icon'; + +interface IMultiselectProps { + options: any; + // disablePreSelectedValues?: boolean; + selectedValues?: any; + displayValue: string; + placeholder?: string; + emptyRecordMsg?: string; + onSelect?: (selectedList:any, selectedItem: any) => void; + onRemove?: (selectedList:any, selectedItem: any) => void; + disable?: boolean; + className?: string; +} + +function useOutsideAlerter(ref, clickEvent) { + useEffect(() => { + function handleClickOutside(event) { + if (ref.current && !ref.current.contains(event.target)) { + clickEvent(); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [ref]); +} + +/** +* Component that alerts if you click outside of it +*/ +function OutsideAlerter(props) { + const wrapperRef = useRef(null); + useOutsideAlerter(wrapperRef, props.outsideClick); + return
{props.children}
; +} + +/** + * + */ +export class Multiselect extends React.Component { + + static defaultProps: IMultiselectProps; + /** + * + */ + constructor(props: IMultiselectProps) { + super(props); + this.state = { + inputValue: '', + options: props.options, + filteredOptions: props.options, + unfilteredOptions: props.options, + selectedValues: Object.assign([], props.selectedValues), + preSelectedValues: Object.assign([], props.selectedValues), + toggleOptionsList: false, + highlightOption: 0, + // keepSearchTerm: props.keepSearchTerm, + groupedObject: [], + }; + // @ts-ignore + this.optionTimeout = null; + // @ts-ignore + this.searchWrapper = React.createRef(); + // @ts-ignore + this.searchBox = React.createRef(); + this.onChange = this.onChange.bind(this); + this.onFocus = this.onFocus.bind(this); + this.onBlur = this.onBlur.bind(this); + this.renderMultiselectContainer = this.renderMultiselectContainer.bind(this); + this.renderSelectedList = this.renderSelectedList.bind(this); + this.onRemoveSelectedItem = this.onRemoveSelectedItem.bind(this); + this.toggelOptionList = this.toggelOptionList.bind(this); + this.onArrowKeyNavigation = this.onArrowKeyNavigation.bind(this); + this.onSelectItem = this.onSelectItem.bind(this); + this.filterOptionsByInput = this.filterOptionsByInput.bind(this); + this.removeSelectedValuesFromOptions = this.removeSelectedValuesFromOptions.bind(this); + this.isSelectedValue = this.isSelectedValue.bind(this); + this.isDisablePreSelectedValues = this.isDisablePreSelectedValues.bind(this); + this.renderNormalOption = this.renderNormalOption.bind(this); + this.listenerCallback = this.listenerCallback.bind(this); + this.resetSelectedValues = this.resetSelectedValues.bind(this); + this.getSelectedItems = this.getSelectedItems.bind(this); + this.getSelectedItemsCount = this.getSelectedItemsCount.bind(this); + this.hideOnClickOutside = this.hideOnClickOutside.bind(this); + this.onCloseOptionList = this.onCloseOptionList.bind(this); + this.isVisible = this.isVisible.bind(this); + } + + /** + * + */ + initialSetValue() { + this.removeSelectedValuesFromOptions(false); + } + + /** + * + */ + resetSelectedValues() { + const { unfilteredOptions } = this.state; + return new Promise((resolve) => { + this.setState({ + selectedValues: [], + preSelectedValues: [], + options: unfilteredOptions, + filteredOptions: unfilteredOptions, + }, () => { + // @ts-ignore + resolve(); + this.initialSetValue(); + }); + }); + } + + /** + * + */ + getSelectedItems() { + return this.state.selectedValues; + } + + /** + * + */ + getSelectedItemsCount() { + return this.state.selectedValues.length; + } + + /** + * + */ + componentDidMount() { + this.initialSetValue(); + // @ts-ignore + this.searchWrapper.current.addEventListener('click', this.listenerCallback); + } + + /** + * + */ + componentDidUpdate(prevProps: IMultiselectProps) { + const { options, selectedValues } = this.props; + const { options: prevOptions, selectedValues: prevSelectedvalues } = prevProps; + if (JSON.stringify(prevOptions) !== JSON.stringify(options)) { + this.setState({ options, filteredOptions: options, unfilteredOptions: options }, this.initialSetValue); + } + if (JSON.stringify(prevSelectedvalues) !== JSON.stringify(selectedValues)) { + this.setState({ selectedValues: Object.assign([], selectedValues), preSelectedValues: Object.assign([], selectedValues) }, this.initialSetValue); + } + } + + /** + * + */ + listenerCallback() { + // @ts-ignore + this.searchBox.current.focus(); + } + + /** + * + */ + componentWillUnmount() { + // @ts-ignore + if (this.optionTimeout) { + // @ts-ignore + clearTimeout(this.optionTimeout); + } + // @ts-ignore + this.searchWrapper.current.removeEventListener('click', this.listenerCallback); + } + + // Skipcheck flag - value will be true when the func called from on deselect anything. + /** + * + */ + removeSelectedValuesFromOptions(skipCheck: boolean) { + const { displayValue } = this.props; + const { selectedValues = [], unfilteredOptions } = this.state; + if (!selectedValues.length && !skipCheck) { + return; + } + const optionList = unfilteredOptions.filter(item => { + return selectedValues.findIndex( + v => v[displayValue] === item[displayValue], + ) === -1 + ? true + : false; + }); + this.setState( + { options: optionList, filteredOptions: optionList }, + this.filterOptionsByInput, + ); + return; + } + + /** + * + */ + onChange(event) { + const { onSearch } = this.props; + this.setState( + { inputValue: event.target.value }, + this.filterOptionsByInput, + ); + if (onSearch) { + onSearch(event.target.value); + } + } + + /** + * + */ + filterOptionsByInput() { + let { options, filteredOptions, inputValue } = this.state; + const { displayValue } = this.props; + options = filteredOptions.filter(i => this.matchValues(i[displayValue], inputValue)); + this.setState({ options }); + } + + /** + * + */ + matchValues(value, search) { + if (value.toLowerCase) { + return value.toLowerCase().indexOf(search.toLowerCase()) > -1; + } + return value.toString().indexOf(search) > -1; + } + + /** + * + */ + onArrowKeyNavigation(e) { + const { + options, + highlightOption, + toggleOptionsList, + inputValue, + selectedValues, + } = this.state; + if (e.keyCode === 8 && !inputValue && selectedValues.length) { + this.onRemoveSelectedItem(selectedValues.length - 1); + } + if (!options.length) { + return; + } + if (e.keyCode === 38) { + if (highlightOption > 0) { + this.setState(previousState => ({ + highlightOption: previousState.highlightOption - 1, + })); + } else { + this.setState({ highlightOption: options.length - 1 }); + } + } else if (e.keyCode === 40) { + if (highlightOption < options.length - 1) { + this.setState(previousState => ({ + highlightOption: previousState.highlightOption + 1, + })); + } else { + this.setState({ highlightOption: 0 }); + } + } else if (e.key === 'Enter' && options.length && toggleOptionsList) { + if (highlightOption === -1) { + return; + } + this.onSelectItem(options[highlightOption]); + } + // TODO: Instead of scrollIntoView need to find better soln for scroll the dropwdown container. + // setTimeout(() => { + // const element = document.querySelector("ul.optionContainer .highlight"); + // if (element) { + // element.scrollIntoView(); + // } + // }); + } + + /** + * + */ + onRemoveSelectedItem(item) { + let { selectedValues, index = 0 } = this.state; + const { onRemove, displayValue } = this.props; + index = selectedValues.findIndex( + i => i[displayValue] === item[displayValue], + ); + selectedValues.splice(index, 1); + onRemove(selectedValues, item); + this.setState({ selectedValues }, () => { + this.removeSelectedValuesFromOptions(true); + }); + } + + /** + * + */ + onSelectItem(item) { + const { selectedValues } = this.state; + const { onSelect } = this.props; + this.setState({ + inputValue: '', + }); + if (this.isSelectedValue(item)) { + this.onRemoveSelectedItem(item); + return; + } + selectedValues.push(item); + onSelect(selectedValues, item); + this.setState({ selectedValues }, () => { + this.removeSelectedValuesFromOptions(true); + }); + } + + /** + * + */ + isSelectedValue(item) { + const { displayValue } = this.props; + const { selectedValues } = this.state; + return ( + selectedValues.filter(i => i[displayValue] === item[displayValue]) + .length > 0 + ); + } + + /** + * + */ + renderOptionList() { + const { emptyRecordMsg } = this.props; + const { options } = this.state; + return ( +
    + {options.length === 0 && {emptyRecordMsg}} + {this.renderNormalOption()} +
+ ); + } + + /** + * + */ + renderNormalOption() { + const { displayValue } = this.props; + const { highlightOption } = this.state; + return this.state.options.map((option, i) => { + const isSelected = this.isSelectedValue(option); + return ( +
  • this.onSelectItem(option)} + > + {option[displayValue]} +
  • + ); + }); + } + + /** + * + */ + renderSelectedList() { + const { displayValue } = this.props; + const { selectedValues } = this.state; + return selectedValues.map((value, index) => ( + + {value[displayValue]} + {!this.isDisablePreSelectedValues(value) && ( +
    this.onRemoveSelectedItem(value)} + > + +
    + )} +
    + )); + } + + /** + * + */ + isDisablePreSelectedValues(value) { + const { displayValue } = this.props; + const { preSelectedValues } = this.state; + if (!preSelectedValues.length) { + return false; + } + return ( + preSelectedValues.filter(i => i[displayValue] === value[displayValue]) + .length > 0 + ); + } + + /** + * + */ + toggelOptionList() { + this.setState({ + toggleOptionsList: !this.state.toggleOptionsList, + highlightOption: 0, + }); + } + + /** + * + */ + onCloseOptionList() { + this.setState({ + toggleOptionsList: false, + highlightOption: 0, + inputValue: '', + }); + } + + /** + * + */ + onFocus(){ + if (this.state.toggleOptionsList) { + // @ts-ignore + clearTimeout(this.optionTimeout); + } else { + this.toggelOptionList(); + } + } + + /** + * + */ + onBlur(){ + this.setState({ inputValue: '' }, this.filterOptionsByInput); + // @ts-ignore + this.optionTimeout = setTimeout(this.onCloseOptionList, 250); + } + + /** + * + */ + isVisible(elem) { + return !!elem && !!(elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length); + } + + /** + * + */ + hideOnClickOutside() { + const element = document.getElementsByClassName('multiselect-container')[0]; + const outsideClickListener = event => { + if (element && !element.contains(event.target) && this.isVisible(element)) { + this.toggelOptionList(); + } + }; + document.addEventListener('click', outsideClickListener); + } + + /** + * + */ + renderMultiselectContainer() { + const { inputValue, toggleOptionsList, selectedValues } = this.state; + const { placeholder, id, name, disable, className } = this.props; + return ( +
    +
    + {this.renderSelectedList()} + +
    +
    { + e.preventDefault(); + }} + > + {this.renderOptionList()} +
    +
    + ); + } + + /** + * + */ + render() { + return ( + + {this.renderMultiselectContainer()} + + ); + } + +} + +Multiselect.defaultProps = { + options: [], + selectedValues: [], + displayValue: 'model', + placeholder: 'Select', + emptyRecordMsg: 'No Options Available', + onSelect: () => {}, + onRemove: () => {}, + id: '', + name: '', + className: '', +} as IMultiselectProps; diff --git a/packages/pl-fe/src/locales/en.json b/packages/pl-fe/src/locales/en.json index 31af902b8..25e34b39c 100644 --- a/packages/pl-fe/src/locales/en.json +++ b/packages/pl-fe/src/locales/en.json @@ -2,7 +2,7 @@ "about.also_available": "Available in:", "accordion.collapse": "Collapse", "accordion.expand": "Expand", - "account.add_or_remove_from_list": "Add or Remove from lists", + "account.add_or_remove_from_list": "Add or remove from lists", "account.avatar.alt": "Avatar", "account.avatar.description": "Avatar description", "account.avatar.with_content": "Avatar for {username}: {alt}", @@ -1084,7 +1084,7 @@ "lightbox.view_context": "View context", "link_preview.more_from_author": "More from {name}", "list.click_to_add": "Click here to add people", - "list_adder.header_title": "Add or Remove from Lists", + "list_adder.header_title": "Add or remove from lists", "lists.account.add": "Add to list", "lists.account.remove": "Remove from list", "lists.delete": "Delete list", @@ -1123,6 +1123,7 @@ "login_form.divider": "or", "login_form.external": "Sign in from remote instance", "login_form.header": "Sign in", + "suspicious_url_warning.body": "This post might include a suspicious link. Please be cautious before entering any personal data or payment information.", "manage_group.blocked_members": "Banned members", "manage_group.confirmation.copy": "Copy link", "manage_group.confirmation.info_1": "As the owner of this group, you can assign staff, delete posts and much more.",