diff --git a/src/features/authFiles/uiState.ts b/src/features/authFiles/uiState.ts index 5631fae..dd1a6a4 100644 --- a/src/features/authFiles/uiState.ts +++ b/src/features/authFiles/uiState.ts @@ -7,6 +7,7 @@ export type AuthFilesUiState = { problemOnly?: boolean; compactMode?: boolean; search?: string; + regexSearchMode?: boolean; page?: number; pageSize?: number; regularPageSize?: number; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1951c66..4bb65d4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -498,10 +498,14 @@ "pagination_info": "Page {{current}} / {{total}} · {{count}} files", "search_label": "Search configs", "search_placeholder": "Filter by name, type, or provider", + "search_regex_placeholder": "Match name, type, or provider with a regex", + "search_regex_invalid": "Enter a valid regex pattern (max {{max}} characters)", + "search_regex_unsafe": "This regex may freeze the page and has been blocked (avoid nested quantifiers, alternation in repeated groups, or backreferences)", "problem_filter_label": "Problem Filter", "problem_filter_only": "Only show problematic credentials", "display_options_label": "Display options", "compact_mode_label": "Compact mode", + "regex_search_mode_label": "Regex mode", "sort_label": "Sort", "sort_default": "Default", "sort_az": "A-Z Name", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index f349ace..343b298 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -498,10 +498,14 @@ "pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов", "search_label": "Поиск конфигов", "search_placeholder": "Фильтр по имени, типу или провайдеру", + "search_regex_placeholder": "Сопоставление имени, типа или провайдера по regex", + "search_regex_invalid": "Введите корректный regex-шаблон (не более {{max}} символов)", + "search_regex_unsafe": "Этот regex может вызвать зависание страницы и был заблокирован (избегайте вложенных квантификаторов, альтернативы в повторяющихся группах или обратных ссылок)", "problem_filter_label": "Фильтр проблем", "problem_filter_only": "Показывать только проблемные учётные данные", "display_options_label": "Параметры отображения", "compact_mode_label": "Компактный режим", + "regex_search_mode_label": "Режим regex", "sort_label": "Сортировка", "sort_default": "По умолчанию", "sort_az": "A-Z Имя", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 60a8042..fc09603 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -498,10 +498,14 @@ "pagination_info": "第 {{current}} / {{total}} 页 · 共 {{count}} 个文件", "search_label": "搜索配置文件", "search_placeholder": "输入名称、类型或提供方关键字", + "search_regex_placeholder": "输入正则表达式匹配名称、类型或提供方", + "search_regex_invalid": "请输入有效的正则表达式(最多 {{max}} 个字符)", + "search_regex_unsafe": "该正则表达式可能导致页面卡顿,已阻止执行(避免嵌套量词、重复分组中的 | 或反向引用)", "problem_filter_label": "问题筛选", "problem_filter_only": "仅显示有问题凭证", "display_options_label": "显示选项", "compact_mode_label": "简略模式", + "regex_search_mode_label": "正则模式", "sort_label": "排序", "sort_default": "默认", "sort_az": "A-Z 名称", diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 225de57..8f1e4be 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -24,6 +24,7 @@ import { IconFilterAll } from '@/components/ui/icons'; import { EmptyState } from '@/components/ui/EmptyState'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { copyToClipboard } from '@/utils/clipboard'; +import { isLikelyUnsafeJsRegex } from '@/utils/regexSafety'; import { MAX_CARD_PAGE_SIZE, MIN_CARD_PAGE_SIZE, @@ -67,6 +68,7 @@ const BATCH_BAR_BASE_TRANSFORM = 'translateX(-50%)'; const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)'; const DEFAULT_REGULAR_PAGE_SIZE = 9; const DEFAULT_COMPACT_PAGE_SIZE = 12; +const MAX_REGEX_SEARCH_PATTERN_LENGTH = 120; export function AuthFilesPage() { const { t } = useTranslation(); @@ -81,6 +83,7 @@ export function AuthFilesPage() { const [problemOnly, setProblemOnly] = useState(false); const [compactMode, setCompactMode] = useState(false); const [search, setSearch] = useState(''); + const [regexSearchMode, setRegexSearchMode] = useState(false); const [page, setPage] = useState(1); const [pageSizeByMode, setPageSizeByMode] = useState({ regular: DEFAULT_REGULAR_PAGE_SIZE, @@ -201,6 +204,9 @@ export function AuthFilesPage() { if (typeof persisted.search === 'string') { setSearch(persisted.search); } + if (typeof persisted.regexSearchMode === 'boolean') { + setRegexSearchMode(persisted.regexSearchMode); + } if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) { setPage(Math.max(1, Math.round(persisted.page))); } @@ -236,6 +242,7 @@ export function AuthFilesPage() { problemOnly, compactMode, search, + regexSearchMode, page, pageSize, regularPageSize: pageSizeByMode.regular, @@ -250,6 +257,7 @@ export function AuthFilesPage() { pageSize, pageSizeByMode, problemOnly, + regexSearchMode, search, sortMode, uiStateHydrated, @@ -368,18 +376,63 @@ export function AuthFilesPage() { return counts; }, [filesMatchingProblemFilter]); + const normalizedSearch = search.trim(); + const { regexSearch, regexSearchErrorKey } = useMemo(() => { + if (!regexSearchMode || !normalizedSearch) { + return { regexSearch: null as RegExp | null, regexSearchErrorKey: undefined as string | undefined }; + } + + if (normalizedSearch.length > MAX_REGEX_SEARCH_PATTERN_LENGTH) { + return { + regexSearch: null, + regexSearchErrorKey: 'auth_files.search_regex_invalid', + }; + } + + if (isLikelyUnsafeJsRegex(normalizedSearch)) { + return { + regexSearch: null, + regexSearchErrorKey: 'auth_files.search_regex_unsafe', + }; + } + + try { + return { regexSearch: new RegExp(normalizedSearch, 'i'), regexSearchErrorKey: undefined }; + } catch { + return { + regexSearch: null, + regexSearchErrorKey: 'auth_files.search_regex_invalid', + }; + } + }, [normalizedSearch, regexSearchMode]); + + const searchError = regexSearchErrorKey + ? t(regexSearchErrorKey, { max: MAX_REGEX_SEARCH_PATTERN_LENGTH }) + : undefined; + const filtered = useMemo(() => { return filesMatchingProblemFilter.filter((item) => { const matchType = filter === 'all' || item.type === filter; - const term = search.trim().toLowerCase(); - const matchSearch = - !term || - item.name.toLowerCase().includes(term) || - (item.type || '').toString().toLowerCase().includes(term) || - (item.provider || '').toString().toLowerCase().includes(term); + const matchSearch = (() => { + if (!normalizedSearch) return true; + if (!regexSearchMode) { + const term = normalizedSearch.toLowerCase(); + return ( + item.name.toLowerCase().includes(term) || + (item.type || '').toString().toLowerCase().includes(term) || + (item.provider || '').toString().toLowerCase().includes(term) + ); + } + + if (!regexSearch) return false; + + return [item.name, item.type, item.provider].some((value) => + regexSearch.test((value || '').toString()) + ); + })(); return matchType && matchSearch; }); - }, [filesMatchingProblemFilter, filter, search]); + }, [filesMatchingProblemFilter, filter, normalizedSearch, regexSearch, regexSearchMode]); const sorted = useMemo(() => { const copy = [...filtered]; @@ -691,7 +744,12 @@ export function AuthFilesPage() { setSearch(e.target.value); setPage(1); }} - placeholder={t('auth_files.search_placeholder')} + placeholder={ + regexSearchMode + ? t('auth_files.search_regex_placeholder') + : t('auth_files.search_placeholder') + } + error={searchError} />