diff --git a/src/features/authFiles/uiState.ts b/src/features/authFiles/uiState.ts index dd1a6a44..5631faea 100644 --- a/src/features/authFiles/uiState.ts +++ b/src/features/authFiles/uiState.ts @@ -7,7 +7,6 @@ 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 0180caf2..e3c92930 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -497,15 +497,11 @@ "pagination_next": "Next", "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)", + "search_placeholder": "Filter by name, type, or provider. Use * as a wildcard", "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 261c6de9..4b2bf270 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -497,15 +497,11 @@ "pagination_next": "Следующая", "pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов", "search_label": "Поиск конфигов", - "search_placeholder": "Фильтр по имени, типу или провайдеру", - "search_regex_placeholder": "Сопоставление имени, типа или провайдера по regex", - "search_regex_invalid": "Введите корректный regex-шаблон (не более {{max}} символов)", - "search_regex_unsafe": "Этот regex может вызвать зависание страницы и был заблокирован (избегайте вложенных квантификаторов, альтернативы в повторяющихся группах или обратных ссылок)", + "search_placeholder": "Фильтр по имени, типу или провайдеру, поддерживается wildcard *", "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 6017afdf..725f26bf 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -497,15 +497,11 @@ "pagination_next": "下一页", "pagination_info": "第 {{current}} / {{total}} 页 · 共 {{count}} 个文件", "search_label": "搜索配置文件", - "search_placeholder": "输入名称、类型或提供方关键字", - "search_regex_placeholder": "输入正则表达式匹配名称、类型或提供方", - "search_regex_invalid": "请输入有效的正则表达式(最多 {{max}} 个字符)", - "search_regex_unsafe": "该正则表达式可能导致页面卡顿,已阻止执行(避免嵌套量词、重复分组中的 | 或反向引用)", + "search_placeholder": "输入名称、类型或提供方关键字,支持 * 通配", "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 8f1e4be0..6916b4a8 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -24,7 +24,6 @@ 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, @@ -68,7 +67,15 @@ 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; + +const escapeWildcardSearchSegment = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const buildWildcardSearch = (value: string): RegExp | null => { + if (!value.includes('*')) return null; + const pattern = value.split('*').map(escapeWildcardSearchSegment).join('.*'); + return new RegExp(pattern, 'i'); +}; export function AuthFilesPage() { const { t } = useTranslation(); @@ -83,7 +90,6 @@ 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, @@ -204,9 +210,6 @@ 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))); } @@ -242,7 +245,6 @@ export function AuthFilesPage() { problemOnly, compactMode, search, - regexSearchMode, page, pageSize, regularPageSize: pageSizeByMode.regular, @@ -257,7 +259,6 @@ export function AuthFilesPage() { pageSize, pageSizeByMode, problemOnly, - regexSearchMode, search, sortMode, uiStateHydrated, @@ -377,62 +378,24 @@ export function AuthFilesPage() { }, [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 wildcardSearch = useMemo(() => buildWildcardSearch(normalizedSearch), [normalizedSearch]); const filtered = useMemo(() => { + const normalizedTerm = normalizedSearch.toLowerCase(); + return filesMatchingProblemFilter.filter((item) => { const matchType = filter === 'all' || item.type === filter; - 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()) - ); - })(); + const matchSearch = + !normalizedSearch || + [item.name, item.type, item.provider].some((value) => { + const content = (value || '').toString(); + return wildcardSearch + ? wildcardSearch.test(content) + : content.toLowerCase().includes(normalizedTerm); + }); return matchType && matchSearch; }); - }, [filesMatchingProblemFilter, filter, normalizedSearch, regexSearch, regexSearchMode]); + }, [filesMatchingProblemFilter, filter, normalizedSearch, wildcardSearch]); const sorted = useMemo(() => { const copy = [...filtered]; @@ -744,12 +707,7 @@ export function AuthFilesPage() { setSearch(e.target.value); setPage(1); }} - placeholder={ - regexSearchMode - ? t('auth_files.search_regex_placeholder') - : t('auth_files.search_placeholder') - } - error={searchError} + placeholder={t('auth_files.search_placeholder')} />
@@ -811,21 +769,6 @@ export function AuthFilesPage() { } />
-
- { - setRegexSearchMode(value); - setPage(1); - }} - ariaLabel={t('auth_files.regex_search_mode_label')} - label={ - - {t('auth_files.regex_search_mode_label')} - - } - /> -
diff --git a/src/utils/regexSafety.ts b/src/utils/regexSafety.ts deleted file mode 100644 index 6363682a..00000000 --- a/src/utils/regexSafety.ts +++ /dev/null @@ -1,166 +0,0 @@ -type GroupState = { - hasInnerVariableQuantifier: boolean; - hasAlternation: boolean; - justOpened: boolean; -}; - -type Quantifier = { - length: number; - min: number; - max: number | null; // null means unbounded - variable: boolean; // can match multiple lengths for the repeated token -}; - -const OUTER_REPEAT_MAX_SAFE_UPPER_BOUND = 9; - -const isDigit = (ch: string | undefined): ch is string => ch !== undefined && ch >= '0' && ch <= '9'; - -const readBraceQuantifier = (pattern: string, index: number): Quantifier | null => { - if (pattern[index] !== '{') return null; - - let i = index + 1; - let minStr = ''; - while (isDigit(pattern[i])) { - minStr += pattern[i]; - i += 1; - } - - if (minStr.length === 0) return null; - const min = Number(minStr); - - let max: number | null = min; - if (pattern[i] === ',') { - i += 1; - let maxStr = ''; - while (isDigit(pattern[i])) { - maxStr += pattern[i]; - i += 1; - } - max = maxStr.length === 0 ? null : Number(maxStr); - } - - if (pattern[i] !== '}') return null; - - const variable = max === null || max !== min; - return { length: i - index + 1, min, max, variable }; -}; - -const readQuantifier = (pattern: string, index: number): Quantifier | null => { - const ch = pattern[index]; - if (ch === '*') return { length: 1, min: 0, max: null, variable: true }; - if (ch === '+') return { length: 1, min: 1, max: null, variable: true }; - if (ch === '?') return { length: 1, min: 0, max: 1, variable: true }; - if (ch !== '{') return null; - return readBraceQuantifier(pattern, index); -}; - -/** - * Heuristic safety check for user-supplied JS regex patterns. - * - * Goal: prevent patterns that are very likely to cause catastrophic backtracking - * (e.g. `^(a+)+$`) from running on the main thread. - * - * Notes: - * - This is intentionally conservative but tries to avoid blocking common safe patterns. - * - We do not execute the regex here; only scan the pattern string. - */ -export function isLikelyUnsafeJsRegex(pattern: string): boolean { - let inCharClass = false; - const groupStack: GroupState[] = [ - { hasInnerVariableQuantifier: false, hasAlternation: false, justOpened: false }, - ]; - - const markInnerVariableQuantifier = () => { - for (let i = 0; i < groupStack.length; i += 1) { - groupStack[i].hasInnerVariableQuantifier = true; - } - }; - - const markAlternation = () => { - for (let i = 0; i < groupStack.length; i += 1) { - groupStack[i].hasAlternation = true; - } - }; - - const isOuterRepeatRisky = (q: Quantifier): boolean => { - // If it cannot repeat more than once, it's not a "repeat group" in the sense that - // triggers catastrophic backtracking (e.g. `(a+)?`). - const max = q.max ?? Number.POSITIVE_INFINITY; - if (max <= 1) return false; - - // Unbounded repetition is the main hazard: `*`, `+`, `{m,}`. - if (q.max === null) return true; - - // Large fixed/variable upper bounds also explode combinatorially with an inner variable quantifier. - return q.max > OUTER_REPEAT_MAX_SAFE_UPPER_BOUND; - }; - - for (let i = 0; i < pattern.length; i += 1) { - const ch = pattern[i]; - - // Reset "justOpened" once we move past the first token inside the group. - const top = groupStack[groupStack.length - 1]; - if (top.justOpened) { - top.justOpened = false; - // `(?...)` group prefixes use `?` immediately after `(` and are not quantifiers. - if (ch === '?') continue; - } - - if (ch === '\\') { - const next = pattern[i + 1]; - // Backreferences often make backtracking far worse. - if (next && next >= '1' && next <= '9') return true; - // Named backreference: \k - if (next === 'k' && pattern[i + 2] === '<') return true; - - // Skip escaped character. - i += 1; - continue; - } - - if (inCharClass) { - if (ch === ']') inCharClass = false; - continue; - } - - if (ch === '[') { - inCharClass = true; - continue; - } - - if (ch === '(') { - groupStack.push({ hasInnerVariableQuantifier: false, hasAlternation: false, justOpened: true }); - continue; - } - - if (ch === ')') { - const group = groupStack.pop(); - if (!group) return true; // unbalanced, treat as unsafe - - const q = readQuantifier(pattern, i + 1); - if ( - q && - isOuterRepeatRisky(q) && - (group.hasInnerVariableQuantifier || group.hasAlternation) - ) { - return true; - } - continue; - } - - if (ch === '|') { - // Alternation inside a repeated group is frequently a backtracking hotspot. - markAlternation(); - continue; - } - - const q = readQuantifier(pattern, i); - if (q) { - if (q.variable) markInnerVariableQuantifier(); - i += q.length - 1; - continue; - } - } - - return false; -}